mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
refactor(server): isolate inbound notice reconciliation and committed triggers
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6ac100dac5
commit
2c348b30ae
10 files changed
+349
-47
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { issueTreeControlService } from "../services/issue-tree-control.js";
|
||||
import { chatInboundWakeNoticeWork } from "../services/chat-inbound-wake-notice-work.js";
|
||||
import { nativeChatProgressWork } from "../services/chat-native-progress-work.js";
|
||||
import { cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "../middleware/auth.js";
|
||||
import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js";
|
||||
@@ -71006,7 +71008,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect(committed.providerRuntime.slackFilePublicationAttempts).toBe(2);
|
||||
});
|
||||
|
||||
async function deferredChatQueueFixture(admissionFails = false) {
|
||||
async function deferredChatQueueFixture(admissionFails = false, beforeAdmission?: (service: ChatChannelService) => Promise<void>) {
|
||||
const fixture = await seedCompany();
|
||||
let ownerId: string | null = null;
|
||||
const configured = await configuredSlackEndpoint(fixture, {
|
||||
@@ -71080,6 +71082,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
}),
|
||||
trigger: id === "1999000.1" ? "mention" : "subscribed_message",
|
||||
});
|
||||
await beforeAdmission?.(configured.service);
|
||||
await send();
|
||||
const [conversation] = await readChatConversationsByEndpointId(configured.endpoint.id);
|
||||
const [action] = await db
|
||||
@@ -71126,7 +71129,101 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("advances pre-run FIFO selection past a suppressed page with microsecond timestamps", async () => {
|
||||
it("wakes pre-run FIFO notices from real admission after quiet and becomes quiet again", async () => {
|
||||
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
||||
let service: ChatChannelService | undefined;
|
||||
const errors: unknown[] = [];
|
||||
const settle = async () => vi.waitFor(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
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(); }
|
||||
};
|
||||
try {
|
||||
const context = await deferredChatQueueFixture(false, async channel => {
|
||||
service = channel;
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, canReconcile: () => true, onError: error => errors.push(error) });
|
||||
await chatInboundWakeNoticeWork(channel).register(coordinator, () => true).ready;
|
||||
await settle(); await quiet();
|
||||
});
|
||||
await settle();
|
||||
expect(await readChatPublicationsByEndpointId(context.endpoint.id)).toEqual([expect.objectContaining({
|
||||
commentId: context.action.payload.commentId,
|
||||
payload: { text: "Your follow-up is queued.", progressState: "queued" },
|
||||
})]);
|
||||
await quiet();
|
||||
await coordinator!.stop();
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, canReconcile: () => true, onError: error => errors.push(error) });
|
||||
await chatInboundWakeNoticeWork(context.service).register(coordinator, () => true).ready;
|
||||
await settle(); await quiet();
|
||||
expect(await readChatPublicationsByEndpointId(context.endpoint.id)).toHaveLength(1);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await coordinator?.stop(); vi.useRealTimers(); await service?.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["tree cancellation", "source removal"] as const)("wakes a visible pre-run FIFO retirement only after %s commits", async change => {
|
||||
const context = await deferredChatQueueFixture();
|
||||
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(1000);
|
||||
expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBeNull();
|
||||
}, { timeout: 10_000 });
|
||||
const notices = () => readChatPublicationsByEndpointId(context.endpoint.id);
|
||||
const expectedText = change === "source removal" ? "This queued message was removed."
|
||||
: "This follow-up was not started. Open the task in Paperclip for details.";
|
||||
const retire = (database: typeof db) => change === "source removal"
|
||||
? issueService(database).tombstoneComment(String(context.action.payload.commentId), {
|
||||
actorType: "user", userId: String(context.action.payload.requestedByActorId),
|
||||
})
|
||||
: issueTreeControlService(database).cancelUnclaimedWakeupsForTree(
|
||||
context.fixture.companyId, context.conversation.issueId, "PRIVATE cancellation",
|
||||
);
|
||||
try {
|
||||
await chatInboundWakeNoticeWork(context.service).register(coordinator, () => true).ready;
|
||||
await settle();
|
||||
await context.service.processPendingPublications(100, { maintenance: false });
|
||||
await settle();
|
||||
expect(await notices()).toEqual([expect.objectContaining({ state: "published" })]);
|
||||
await expect(db.transaction(async tx => {
|
||||
await retire(tx as unknown as typeof db);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(await notices()).toHaveLength(1);
|
||||
throw new Error("outer cancellation rollback");
|
||||
})).rejects.toThrow("outer cancellation rollback");
|
||||
await settle(); expect(await notices()).toHaveLength(1);
|
||||
await retire(db);
|
||||
await settle();
|
||||
expect(await notices()).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||
payload: { text: expectedText, progressState: "failed" },
|
||||
})]));
|
||||
expect(await notices()).toHaveLength(2);
|
||||
await context.service.processPendingPublications(100, { maintenance: false });
|
||||
await settle();
|
||||
expect(context.runtime.endpoints.get(context.endpoint.id)!.edits).toEqual([expect.objectContaining({
|
||||
text: expectedText,
|
||||
})]);
|
||||
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 { await coordinator.stop(); vi.useRealTimers(); await context.service.shutdown(); }
|
||||
});
|
||||
|
||||
it.each(["manual", "scoped"] as const)("advances pre-run FIFO selection past a suppressed page with microsecond timestamps (%s)", async mode => {
|
||||
const context = await deferredChatQueueFixture();
|
||||
const rejectedIds = Array.from({ length: 50 }, () => randomUUID());
|
||||
try {
|
||||
@@ -71142,9 +71239,15 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
})));
|
||||
// Rejected current authority must not keep the same full page at the
|
||||
// head of every bounded pass and starve the later admitted message.
|
||||
const first = await context.service.enqueueInboundWakeupPublications(50);
|
||||
const second = await context.service.enqueueInboundWakeupPublications(50);
|
||||
expect(first + second).toBe(1);
|
||||
if (mode === "manual") {
|
||||
const first = await context.service.enqueueInboundWakeupPublications(50);
|
||||
const second = await context.service.enqueueInboundWakeupPublications(50);
|
||||
expect(first + second).toBe(1);
|
||||
} else {
|
||||
expect(await context.service.reconcileInboundWakeNotices({
|
||||
scope: { companyId: context.fixture.companyId, issueId: context.conversation.issueId }, limit: 50,
|
||||
})).toEqual({ inserted: 1, contended: false });
|
||||
}
|
||||
const rows = await readChatPublicationsByEndpointId(context.endpoint.id);
|
||||
expect(rows).toEqual([expect.objectContaining({
|
||||
commentId: context.action.payload.commentId,
|
||||
@@ -71157,6 +71260,48 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates pre-run FIFO reconciliation scopes and exhausts published history", async () => {
|
||||
const first = await deferredChatQueueFixture();
|
||||
const second = await deferredChatQueueFixture();
|
||||
try {
|
||||
const scope = { companyId: first.fixture.companyId, issueId: first.conversation.issueId };
|
||||
expect(await first.service.loadInboundWakeNoticeScopes({ companyId: scope.companyId })).toEqual([scope]);
|
||||
expect(await first.service.reconcileInboundWakeNotices({ scope })).toEqual({ inserted: 1, contended: false });
|
||||
expect(await readChatPublicationsByEndpointId(second.endpoint.id)).toEqual([]);
|
||||
expect(await first.service.loadInboundWakeNoticeScopes(scope)).toEqual([]);
|
||||
expect(await first.service.reconcileInboundWakeNotices({ scope })).toEqual({ inserted: 0, contended: false });
|
||||
expect(await second.service.reconcileInboundWakeNotices({
|
||||
scope: { companyId: second.fixture.companyId, issueId: second.conversation.issueId },
|
||||
})).toEqual({ inserted: 1, contended: false });
|
||||
} finally {
|
||||
await first.service.shutdown(); await second.service.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a contended pre-run FIFO projection and settles cancelled input without a visible notice", async () => {
|
||||
const context = await deferredChatQueueFixture();
|
||||
let release!: () => void;
|
||||
let locked!: () => void;
|
||||
const hold = new Promise<void>(resolve => { release = resolve; });
|
||||
const ready = new Promise<void>(resolve => { locked = resolve; });
|
||||
const holder = db.transaction(async tx => {
|
||||
await tx.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, context.action.id)).for("update");
|
||||
locked(); await hold;
|
||||
await tx.update(agentWakeupRequests).set({ status: "cancelled" }).where(eq(agentWakeupRequests.id, context.action.id));
|
||||
});
|
||||
try {
|
||||
await ready;
|
||||
const scope = { companyId: context.fixture.companyId, issueId: context.conversation.issueId };
|
||||
expect(await context.service.reconcileInboundWakeNotices({ scope })).toEqual({ inserted: 0, contended: true });
|
||||
expect(await readChatPublicationsByEndpointId(context.endpoint.id)).toEqual([]);
|
||||
release(); await holder;
|
||||
expect(await context.service.reconcileInboundWakeNotices({ scope })).toEqual({ inserted: 0, contended: false });
|
||||
expect(await context.service.loadInboundWakeNoticeScopes(scope)).toEqual([]);
|
||||
} finally {
|
||||
release(); await holder; await context.service.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps one durable pre-run FIFO notice across coalescing and restart, then reuses only its successor lane", async () => {
|
||||
const context = await deferredChatQueueFixture();
|
||||
let restarted: ChatChannelService | null = null;
|
||||
|
||||
@@ -1262,7 +1262,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
withCredentialMutationLease, teamsFileRetryAfter, isShuttingDown: () => shuttingDown,
|
||||
get teamsFileUploadRequest() { return options.teamsFileUploadRequest; },
|
||||
});
|
||||
const { enqueueInboundWakeupPublications, authorizeInboundWakePublication } = createInboundWakeNotices(db, { authorizeInboundWakeup, enqueueFailedChatRetryPublications, authorizeRetainedChatSourcePublication, isExternalActionAuthorizationChange, isShuttingDown: () => shuttingDown });
|
||||
const { enqueueInboundWakeupPublications, loadInboundWakeNoticeScopes, reconcileInboundWakeNotices, authorizeInboundWakePublication } = createInboundWakeNotices(db, { authorizeInboundWakeup, enqueueFailedChatRetryPublications, authorizeRetainedChatSourcePublication, isExternalActionAuthorizationChange, isShuttingDown: () => shuttingDown });
|
||||
|
||||
async function authorizeRetainedChatSourcePublication(
|
||||
tx: DbOrTransaction,
|
||||
@@ -3554,6 +3554,8 @@ return {
|
||||
reconcileProviderRuntimes,
|
||||
processPendingPublications,
|
||||
enqueueInboundWakeupPublications,
|
||||
loadInboundWakeNoticeScopes,
|
||||
reconcileInboundWakeNotices,
|
||||
schedulePendingPublications,
|
||||
processPendingDeliveries,
|
||||
processQueuedDeliveries: () => processPendingDeliveries(25, undefined, false),
|
||||
|
||||
@@ -18,9 +18,13 @@ type NoticeOptions = {
|
||||
isExternalActionAuthorizationChange: (error: unknown) => boolean;
|
||||
isShuttingDown: () => boolean;
|
||||
};
|
||||
export type InboundWakeNoticeScope = { companyId: string; issueId?: string };
|
||||
type NoticeCursor = { createdAt: string; id: string };
|
||||
type NoticeCandidate = { action: typeof chatActions.$inferSelect; cursorCreatedAt: string };
|
||||
|
||||
export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
const { authorizeInboundWakeup, enqueueFailedChatRetryPublications, authorizeRetainedChatSourcePublication, isExternalActionAuthorizationChange } = options;
|
||||
let inboundWakeNoticeCursor: { createdAt: string; id: string } | null = null;
|
||||
let inboundWakeNoticeCursor: NoticeCursor | null = null;
|
||||
async function currentInboundWakeNotice(
|
||||
tx: DbOrTransaction,
|
||||
action: typeof chatActions.$inferSelect,
|
||||
@@ -130,11 +134,8 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
return Boolean(link);
|
||||
}
|
||||
|
||||
async function enqueueInboundWakeupPublications(limit = 50): Promise<number> {
|
||||
if (options.isShuttingDown()) return 0;
|
||||
const retryInserted = await enqueueFailedChatRetryPublications(limit);
|
||||
async function noticeCandidates(limit: number, cursor: NoticeCursor | null, scope?: InboundWakeNoticeScope): Promise<NoticeCandidate[]> {
|
||||
const owner = alias(agentWakeupRequests, "chat_notice_owner");
|
||||
const cursor = inboundWakeNoticeCursor;
|
||||
// Keep UUID columns indexable. Match only canonical lowercase UUID text,
|
||||
// preserving the old comparison while malformed payloads match nothing.
|
||||
const payloadUuid = (value: SQL) =>
|
||||
@@ -161,7 +162,7 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
${chatActions.endpointId}::text || ':' || ${chatActions.conversationId}::text)`;
|
||||
// A bounded keyset sweep, not an in-memory work queue. Advancing even past
|
||||
// revoked candidates prevents an inaccessible old message starving others.
|
||||
const candidates = await db
|
||||
return db
|
||||
.select({ action: chatActions, cursorCreatedAt: sql<string>`${chatActions.createdAt}::text` })
|
||||
.from(chatActions)
|
||||
.innerJoin(
|
||||
@@ -175,6 +176,9 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.kind, "inbound_wakeup"),
|
||||
sql`${payloadIssueId} is not null`,
|
||||
scope ? eq(chatActions.companyId, scope.companyId) : undefined,
|
||||
scope?.issueId ? eq(payloadIssueId, scope.issueId) : undefined,
|
||||
inArray(chatActions.status, ["processed", "failed"]),
|
||||
eq(owner.companyId, chatActions.companyId),
|
||||
or(
|
||||
@@ -206,13 +210,21 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
)
|
||||
.orderBy(asc(chatActions.createdAt), asc(chatActions.id))
|
||||
.limit(Math.max(1, Math.min(limit, 200)));
|
||||
}
|
||||
|
||||
function continuation(candidates: NoticeCandidate[], limit: number): NoticeCursor | null {
|
||||
const last = candidates.at(-1);
|
||||
inboundWakeNoticeCursor =
|
||||
candidates.length >= Math.max(1, Math.min(limit, 200)) && last
|
||||
? { createdAt: last.cursorCreatedAt, id: last.action.id }
|
||||
: null;
|
||||
let inserted = retryInserted;
|
||||
return candidates.length === limit && last
|
||||
? { createdAt: last.cursorCreatedAt, id: last.action.id }
|
||||
: null;
|
||||
}
|
||||
|
||||
async function projectNotices(candidates: NoticeCandidate[], signal?: AbortSignal) {
|
||||
let inserted = 0;
|
||||
let contended = false;
|
||||
for (const { action } of candidates) {
|
||||
signal?.throwIfAborted();
|
||||
if (options.isShuttingDown()) throw new Error("Inbound wake notice reconciliation stopped");
|
||||
try {
|
||||
inserted += await db.transaction(async (tx) => {
|
||||
const [receipt] = await tx
|
||||
@@ -292,15 +304,58 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
return rows.length;
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!isExternalActionAuthorizationChange(error) &&
|
||||
!isExternalChatWaitAuthorizationContention(error) &&
|
||||
!(error instanceof NativeChatReviewPresentationContentionError)
|
||||
)
|
||||
throw error;
|
||||
if (isExternalChatWaitAuthorizationContention(error)
|
||||
|| error instanceof NativeChatReviewPresentationContentionError) contended = true;
|
||||
else if (!isExternalActionAuthorizationChange(error)) throw error;
|
||||
}
|
||||
}
|
||||
return inserted;
|
||||
return { inserted, contended };
|
||||
}
|
||||
|
||||
/** The explicit/manual API keeps its bounded cursor and combined retry-notice
|
||||
* behavior. Background owners reconcile the two duties independently. */
|
||||
async function enqueueInboundWakeupPublications(limit = 50): Promise<number> {
|
||||
if (options.isShuttingDown()) return 0;
|
||||
const retryInserted = await enqueueFailedChatRetryPublications(limit);
|
||||
const pageSize = Math.max(1, Math.min(limit, 200));
|
||||
const candidates = await noticeCandidates(pageSize, inboundWakeNoticeCursor);
|
||||
inboundWakeNoticeCursor = continuation(candidates, pageSize);
|
||||
return retryInserted + (await projectNotices(candidates)).inserted;
|
||||
}
|
||||
|
||||
async function loadInboundWakeNoticeScopes(scope?: InboundWakeNoticeScope, signal?: AbortSignal) {
|
||||
const scopes = new Map<string, { companyId: string; issueId: string }>();
|
||||
let cursor: NoticeCursor | null = null;
|
||||
do {
|
||||
signal?.throwIfAborted();
|
||||
const candidates = await noticeCandidates(200, cursor, scope);
|
||||
for (const { action } of candidates) {
|
||||
// The candidate SQL already requires a canonical UUID issue binding.
|
||||
const issueId = String(action.payload.issueId);
|
||||
scopes.set(`${action.companyId}:${issueId}`, { companyId: action.companyId, issueId });
|
||||
}
|
||||
cursor = continuation(candidates, 200);
|
||||
} while (cursor);
|
||||
return [...scopes.values()];
|
||||
}
|
||||
|
||||
async function reconcileInboundWakeNotices(input: {
|
||||
scope: InboundWakeNoticeScope; limit?: number; signal?: AbortSignal;
|
||||
}) {
|
||||
const limit = Math.max(1, Math.min(input.limit ?? 100, 1000));
|
||||
let inserted = 0;
|
||||
let contended = false;
|
||||
let cursor: NoticeCursor | null = null;
|
||||
do {
|
||||
input.signal?.throwIfAborted();
|
||||
const pageSize = Math.min(200, limit - inserted);
|
||||
const candidates = await noticeCandidates(pageSize, cursor, input.scope);
|
||||
const result = await projectNotices(candidates, input.signal);
|
||||
inserted += result.inserted;
|
||||
contended ||= result.contended;
|
||||
cursor = continuation(candidates, pageSize);
|
||||
} while (cursor && inserted < limit);
|
||||
return { inserted, contended };
|
||||
}
|
||||
|
||||
async function authorizeInboundWakePublication(
|
||||
@@ -380,5 +435,5 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return { enqueueInboundWakeupPublications, authorizeInboundWakePublication };
|
||||
return { enqueueInboundWakeupPublications, loadInboundWakeNoticeScopes, reconcileInboundWakeNotices, authorizeInboundWakePublication };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import type { InboundWakeNoticeScope } from "./chat-channels/inbound/wakeup-notices.js";
|
||||
|
||||
export function notifyChatInboundWakeNotices(tx: object, scope: InboundWakeNoticeScope) {
|
||||
return notifyDeliveryWork(tx, DELIVERY_QUEUES.chatInboundWakeNotices,
|
||||
JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId }));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ChatChannelService } from "./chat-channels.js";
|
||||
import type { InboundWakeNoticeScope } from "./chat-channels/inbound/wakeup-notices.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import { registerScopedDeadlineWork } from "./scoped-deadline-work.js";
|
||||
|
||||
type Entry = { id: string; companyId: string; issueId: string; dueAt: number };
|
||||
const batchSize = 100;
|
||||
|
||||
/** Admission receipts own queued/not-started/removed notices. The channel
|
||||
* service retains authorization and publication policy; this owner schedules
|
||||
* only unfinished batches, contention, and failed reconciliation. */
|
||||
export function chatInboundWakeNoticeWork(service: Pick<ChatChannelService,
|
||||
"loadInboundWakeNoticeScopes" | "reconcileInboundWakeNotices">) {
|
||||
async function load(scope?: InboundWakeNoticeScope, signal?: AbortSignal): Promise<Entry[]> {
|
||||
return (await service.loadInboundWakeNoticeScopes(scope, signal)).map(row => ({
|
||||
...row, id: `${row.companyId}:${row.issueId}`, dueAt: 0,
|
||||
}));
|
||||
}
|
||||
async function check(entry: Entry, signal?: AbortSignal): Promise<Entry | null> {
|
||||
const result = await service.reconcileInboundWakeNotices({ scope: entry, limit: batchSize, signal });
|
||||
if (result.inserted === batchSize) return { ...entry, dueAt: Date.now() };
|
||||
return result.contended ? { ...entry, dueAt: Date.now() + 1000 } : null;
|
||||
}
|
||||
function register(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
|
||||
return registerScopedDeadlineWork(coordinator, {
|
||||
queue: DELIVERY_QUEUES.chatInboundWakeNotices,
|
||||
decodeScope: (hint: string): InboundWakeNoticeScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
|
||||
matches: (entry: Entry, scope?: InboundWakeNoticeScope) => !scope ||
|
||||
(entry.companyId === scope.companyId && (!scope.issueId || entry.issueId === scope.issueId)),
|
||||
load, check,
|
||||
}, canRun);
|
||||
}
|
||||
return { load, check, register };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { notifyChatNativeProgress } from "./chat-native-progress-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
@@ -10,4 +11,6 @@ export async function notifyChatRunMilestones(tx: object, scope: ChatRunMileston
|
||||
// Lifecycle and coarse native progress share lineage and authority. Extend
|
||||
// this eligibility boundary once rather than adding wrappers to its callers.
|
||||
await notifyChatNativeProgress(tx, scope);
|
||||
// Admission and current chat authority also govern saved queue notices.
|
||||
await notifyChatInboundWakeNotices(tx, scope);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export const DELIVERY_QUEUES = {
|
||||
chatInbound: "chat-inbound",
|
||||
chatPublications: "chat-publications",
|
||||
chatNativeProgress: "chat-native-progress",
|
||||
chatInboundWakeNotices: "chat-inbound-wake-notices",
|
||||
chatRunMilestones: "chat-run-milestones",
|
||||
chatReceipts: "chat-slack-receipts",
|
||||
browser: "browser-use",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -1178,28 +1179,31 @@ export function issueTreeControlService(db: Db) {
|
||||
const issueIds = treeIssues.map((issue) => issue.id);
|
||||
if (issueIds.length === 0) return [];
|
||||
const now = new Date();
|
||||
return db
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]),
|
||||
isNull(agentWakeupRequests.runId),
|
||||
inArray(sql<string | null>`${agentWakeupRequests.payload} ->> 'issueId'`, issueIds),
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
id: agentWakeupRequests.id,
|
||||
agentId: agentWakeupRequests.agentId,
|
||||
reason: agentWakeupRequests.reason,
|
||||
payload: agentWakeupRequests.payload,
|
||||
});
|
||||
return db.transaction(async tx => {
|
||||
await notifyChatInboundWakeNotices(tx, { companyId });
|
||||
return tx
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]),
|
||||
isNull(agentWakeupRequests.runId),
|
||||
inArray(sql<string | null>`${agentWakeupRequests.payload} ->> 'issueId'`, issueIds),
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
id: agentWakeupRequests.id,
|
||||
agentId: agentWakeupRequests.agentId,
|
||||
reason: agentWakeupRequests.reason,
|
||||
payload: agentWakeupRequests.payload,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
||||
import { notifyApprovalWork } from "./approval-work-notifications.js";
|
||||
import { notifyDecisionTargetWork } from "./decision-target-work.js";
|
||||
@@ -12485,6 +12486,11 @@ export function issueService(db: Db) {
|
||||
};
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [source] = await tx.select({ companyId: issueComments.companyId, issueId: issueComments.issueId })
|
||||
.from(issueComments).where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt)))
|
||||
.for("update");
|
||||
if (!source) return null;
|
||||
await notifyChatInboundWakeNotices(tx, source);
|
||||
const now = new Date();
|
||||
const [comment] = await tx
|
||||
.update(issueComments)
|
||||
|
||||
Reference in new issue
Block a user