Carry final qualified cards repair through 18384

Normal parent merge; only the three qualified status-card paths change. Preserve every other owned and upstream blob.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-31 10:19:44 -05:00
commit 274d50430d
3 files changed
+177 -6

No files matched your search

+129
View File
@@ -11,6 +11,7 @@ import {
companyMemberships,
costEvents,
createDb,
databaseWorkPending,
documentRevisions,
documents,
heartbeatRuns,
@@ -19,6 +20,7 @@ import {
issues,
statusCards,
statusCardUpdates,
subscribeDatabaseWork,
} from "@paperclipai/db";
import {
defaultStatusCardRefreshPolicy,
@@ -33,6 +35,7 @@ import { instanceSettingsService } from "../services/instance-settings.js";
import type { IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
import { issueService } from "../services/issues.js";
import { statusCardService } from "../services/status-cards.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -703,6 +706,132 @@ describeEmbeddedPostgres("status card routes", () => {
}
});
it.each([
...(["creator", "deduplicated"] as const).flatMap(winnerKind =>
(["active", "done", "cancelled"] as const).map(outcome => ({ winnerKind, outcome, requestTrigger: "interval" as const }))),
...(["creator", "deduplicated"] as const).map(winnerKind => ({ winnerKind, outcome: "active" as const, requestTrigger: "manual" as const })),
])("preserves a same-revision $winnerKind winner after summary and $outcome via $requestTrigger when its loser resumes", async ({ winnerKind, outcome, requestTrigger }) => {
const company = await seedCompany();
const summarizer = await seedSummarizer(company.id);
const service = statusCardService(db);
const realIssues = issueService(db);
await realIssues.create(company.id, { title: "Watched blocker", status: "blocked", priority: "medium" });
const card = await service.create(company.id, {
interestPrompt: "Completed winner race", titlePinned: false,
refreshPolicy: { ...defaultStatusCardRefreshPolicy, mode: "interval", intervalMinutes: 15 },
}, { agentId: null, userId: "board-user" });
await db.update(statusCards).set({ state: "active", fingerprint: {},
queries: [{ scope: "issues", status: ["blocked"], sort: "updated", limit: 20, offset: 0 }] as typeof card.queries,
}).where(eq(statusCards.id, card.id));
const gates = [Promise.withResolvers<void>(), Promise.withResolvers<void>()];
const reached: string[] = [];
const start = (index: number) => statusCardService(db, { issuesSvc: {
...realIssues,
update: async (...args: Parameters<typeof realIssues.update>) => {
const result = await realIssues.update(...args);
if (args[1].description) { reached[index] = args[0]; await gates[index]!.promise; }
return result;
},
} }).requestRefresh(card.id, { trigger: requestTrigger });
const creator = start(0);
let deduplicated: ReturnType<typeof start> | undefined;
try {
await vi.waitFor(() => expect(reached[0]).toBeTruthy());
deduplicated = start(1);
await vi.waitFor(() => expect(reached[1]).toBe(reached[0]));
const winnerIndex = winnerKind === "creator" ? 0 : 1;
gates[winnerIndex]!.resolve();
const winner = await (winnerIndex === 0 ? creator : deduplicated);
expect(winner.enqueued).toBe(true);
expect(winner.alreadyGenerating).toBe(winnerKind === "deduplicated");
const run = await seedRun(company.id, summarizer.id);
await db.update(issues).set({ checkoutRunId: run.id }).where(eq(issues.id, reached[0]!));
await service.writeSummary(card.id, {
generationIssueId: reached[0]!, markdown: "Completed winner summary",
changeSummary: "Summarized watched work", model: "test",
}, { agentId: summarizer.id, runId: run.id });
if (outcome !== "active") await realIssues.update(reached[0]!, { status: outcome });
expect(await service.getById(card.id)).toMatchObject({ generatingIssueId: null });
const completed = await realIssues.getById(reached[0]!);
expect(completed).toMatchObject({ status: outcome === "active" ? "todo" : outcome });
gates[1 - winnerIndex]!.resolve();
const loser = await (winnerIndex === 0 ? deduplicated : creator);
expect(loser).toMatchObject({ enqueued: false, alreadyGenerating: false, generatingIssue: null });
expect(await realIssues.getById(reached[0]!)).toMatchObject({
status: completed!.status, completedAt: completed!.completedAt, cancelledAt: completed!.cancelledAt,
});
const history = await service.listUpdates(card.id);
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({ generationIssueId: reached[0], status: "ok", finishedAt: expect.any(Date) });
expect(await service.getById(card.id)).toMatchObject({ generatingIssueId: null, state: "active" });
if (requestTrigger === "manual") {
const independent = await service.requestRefresh(card.id, { trigger: "manual" });
expect(independent.enqueued).toBe(true);
expect(independent.generatingIssue!.id).not.toBe(completed!.id);
}
} finally {
gates.forEach(gate => gate.resolve());
await Promise.allSettled([creator, ...(deduplicated ? [deduplicated] : [])]);
}
});
it("cancels an unclaimed archived refresh with one database connection", async () => {
const company = await seedCompany();
await seedSummarizer(company.id);
const single = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 });
const service = statusCardService(single);
const realIssues = issueService(single);
let pending: Promise<unknown> | undefined;
let unsubscribe: (() => void) | undefined;
const signals: Array<{ event: string; pending: boolean; hints: string[] }> = [];
try {
const card = await service.create(company.id, {
interestPrompt: "One connection cleanup", titlePinned: false, refreshPolicy: defaultStatusCardRefreshPolicy,
}, { agentId: null, userId: "board-user" });
await single.update(statusCards).set({ state: "active",
queries: [{ scope: "issues", status: ["blocked"], sort: "updated", limit: 20, offset: 0 }] as typeof card.queries,
}).where(eq(statusCards.id, card.id));
const racing = statusCardService(single, { issuesSvc: {
...realIssues,
update: async (...args: Parameters<typeof realIssues.update>) => {
const result = await realIssues.update(...args);
if (args[1].description) {
await service.update((await service.getById(card.id))!, {
archived: true,
}, { agentId: null, userId: "board-user" });
// Observe only the losing task's canonical cancellation, after the
// independent archive transaction has settled.
unsubscribe = subscribeDatabaseWork(single, DELIVERY_QUEUES.statusCardEvaluation, (event, hints) => {
signals.push({ event, pending: databaseWorkPending(single, DELIVERY_QUEUES.statusCardEvaluation), hints: [...hints ?? []] });
});
}
return result;
},
} });
let settled = false;
let rejection: unknown;
pending = racing.requestRefresh(card.id).then(() => { settled = true; }, error => {
rejection = error; settled = true;
});
await vi.waitFor(() => expect(settled).toBe(true), { timeout: 2_000 });
expect(rejection).toMatchObject({ status: 409 });
expect(await single.select().from(issues).where(eq(issues.companyId, company.id))).toEqual([
expect.objectContaining({ status: "cancelled" }),
]);
expect(await service.listUpdates(card.id)).toHaveLength(0);
const cancellation = signals.filter(signal => signal.hints.includes(`changes:${company.id}`));
expect(cancellation).toContainEqual(expect.objectContaining({ event: "intent", pending: true }));
expect(cancellation.filter(signal => signal.event === "settled")).toEqual([
expect.objectContaining({ event: "settled", pending: false }),
]);
} finally {
unsubscribe?.();
await single.$client.end({ timeout: 1 });
await pending;
}
});
it("finalizes cancelled generation tasks as failed ledger entries", async () => {
const company = await seedCompany();
await enableStatusCards();
+35 -6
View File
@@ -27,7 +27,8 @@ import { logger } from "../middleware/logger.js";
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
import { builtInAgentService } from "./built-in-agents.js";
import { companySearchService } from "./company-search.js";
import { issueService } from "./issues.js";
import { executeIssuePostCommitActions, issueService, type IssuePostCommitAction } from "./issues.js";
import { publishActivity, type ActivityPublication } from "./activity-log.js";
import { SUMMARIZER_BUILT_IN_KEY } from "./summary-slots.js";
import {
buildStatusCardFingerprint,
@@ -643,6 +644,33 @@ export function statusCardService(
? membershipAt : interval;
}
async function cancelUnclaimedRefreshTask(card: StatusCardRow, issueId: string) {
const postCommitActions: IssuePostCommitAction[] = [];
const publications: ActivityPublication[] = [];
const admitted = await db.transaction(async tx => {
// Match the canonical mutation's issue-first lock order. A terminal
// transition cannot race this recheck and have its receipt rewritten.
const [current] = await tx.select({ status: issues.status }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, card.companyId))).for("update");
if (!current) return false;
const [generation] = await tx.select({ id: statusCardUpdates.id }).from(statusCardUpdates)
.where(and(eq(statusCardUpdates.cardId, card.id), eq(statusCardUpdates.generationIssueId, issueId))).limit(1);
// A failed revision CAS excludes any later claim of this revision's task.
// A summary clears the claim and records its ledger atomically, so even
// an active task whose winner already finished remains admitted work.
if (generation) return true;
if (TERMINAL_ISSUE_STATUSES.has(current.status)) return false;
// Bind preflight readers to this transaction too: a root-pool reader
// would wait forever when this transaction owns its only connection.
await issueService(tx as unknown as Db).update(issueId,
{ status: "cancelled", companyGuard: card.companyId }, tx, publications, postCommitActions);
return false;
});
for (const publication of publications) publishActivity(publication);
await executeIssuePostCommitActions(db, postCommitActions);
return admitted;
}
async function refreshCurrentCard(cardId: string, input: {
full?: boolean;
trigger?: "manual" | "interval" | "reactive" | "restore";
@@ -782,14 +810,15 @@ export function statusCardService(
if (!next) {
const winner = await getById(card.id);
if (!winner?.generatingIssueId) {
if (!TERMINAL_ISSUE_STATUSES.has(generationIssue!.status)) {
await issuesSvc().update(generationIssue!.id, { status: "cancelled" });
const admitted = await cancelUnclaimedRefreshTask(card, generationIssue!.id);
if (winner && !winner.archivedAt) {
if (admitted) return { card: winner, generatingIssue: null, alreadyGenerating: false, enqueued: false, kind, changes };
return undefined;
}
if (winner && !winner.archivedAt) return undefined;
throw conflict("Status-card refresh claim was lost");
}
if (generationIssue!.id !== winner.generatingIssueId && !TERMINAL_ISSUE_STATUSES.has(generationIssue!.status)) {
await issuesSvc().update(generationIssue!.id, { status: "cancelled" });
if (generationIssue!.id !== winner.generatingIssueId) {
await cancelUnclaimedRefreshTask(card, generationIssue!.id);
}
const winnerIssue = await db.select().from(issues).where(eq(issues.id, winner.generatingIssueId)).then((rows) => rows[0] ?? null);
return { card: winner, generatingIssue: winnerIssue, alreadyGenerating: true, enqueued: false, kind, changes };