fix: preserve scoped status-card work across mutations and recovery

Complete transactional source coverage, cached feature gating, startup scope reconstruction, off-page relative query expiry and busy-lock retry. Verify producer, persistence, failure, budget resume, compatibility and startup paths with isolated PostgreSQL and focused tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 17:50:38 -05:00
1 parent 938e22aa21
commit a12f68ef13
24 files changed
+524 -125

No files matched your search

+91 -22
View File
@@ -1,33 +1,102 @@
# Status-card evaluation deadlines
Baseline: `76fde891ccc29283b3f5c8dcdce089c5b3d937d1`.
Status-card evaluation uses existing cards, updates and issues as durable truth.
`index.ts` registers a typed worker. The status-card owner controls evaluation,
generation claims, assignment through the ordinary issue wake API, and failed
assignment cancellation. Shared delivery infrastructure controls transaction
settlement, coalescing, retries, admission drain, standby and shutdown.
Status-card ownership covers evaluation policy, card claims, generation, ordinary assignment wakeups, and failed-assignment cancellation. `index.ts` registers a typed capability. Shared delivery/deadline infrastructure owns transaction settlement, coalescing, admission/drain, retries and shutdown. Existing cards, updates and issues remain durable truth; no timer table or sleep-safety exemption is introduced.
Manual cards have no automatic obligation. Interval cards retain the requested
evaluation cadence. Reactive cards evaluate committed source changes and retain
only an actual debounce, hourly rate, daily budget, active-hour or saved-query
membership deadline. Unchanged cards disarm. A changed experimental setting
invalidates the worker's cached feature gate. Other source writes do not reread
settings. Startup reconstructs every disarmed reactive scope together with any
concurrent company-scoped hint, without moving saved eligibility/retry deadlines.
Manual cards have no automatic obligation. Interval cards retain their requested evaluation cadence. Reactive cards evaluate committed source changes, retain the actual debounce/rate/budget/active-hours deadline while relevant changes remain, and disarm once unchanged. Saved relative-search windows retain their actual membership expiry; the duration interpretation stays with company search. Startup reconstructs existing work without advancing business timestamps.
Company search reports the earliest matching `updatedWithin` expiry through an
optional internal callback in its existing aggregate query. It includes rows
outside the returned page: an expiring priority-ranked row ahead of an offset
can change the watched set. The callback exposes only a normalized deadline;
ordinary search consumers and API responses retain their existing contract.
Overlapping query scopes can require an evaluation when one query expires even
if another still retains the same issue; no generation occurs without a filtered
card change. No recurring scan replaces the expired query obligation.
Scheduled reactive source events can preempt a future evaluation. A feature-owned transaction advisory lock serializes scheduler evaluation across connections while existing card/issue services perform their own committed writes. The lock holder has no card row lock, avoiding self-deadlock. Existing next-evaluation CAS, generating-issue ownership and issue idempotency keys remain required, including for manual refreshes and older server instances that do not use the advisory lock. The persisted five-minute claim remains the failure retry after a scheduled refresh cannot finish. Internal timers do not establish a Cloud wake alarm.
## Claims and recovery
## Producer audit (in progress)
A feature-owned PostgreSQL transaction advisory lock serializes scheduled
per-card evaluation across connections. Its transaction holds no card row lock
while existing issue/card services commit through their own transactions. The
lock remains held through actual refresh settlement. Existing card deadline CAS,
generating-issue ownership and issue idempotency keys remain in force. Manual
refresh and older instances continue to use those existing guards. A busy lock
retains a company-scoped retry; consuming a hint is not successful evaluation.
A failed scheduled refresh retains the existing five-minute durable claim.
| Source | Owning committed boundary | State |
| --- | --- | --- |
| Card create/config/archive/delete, compile, query/summary writeback, evaluation | status-card mutation transactions | wired; ordinary lifecycle exercised |
| Experimental feature flag | instance settings update transaction | wired; disable exercised |
| Company pause/archive/reactivate/delete | company owning transactions | wired; lifecycle acceptance remains |
| Ordinary issue create/update/remove | issue service transactions | wired; create/change exercised |
| Comments create/delete/tombstone | common insert and issue mutation transactions | wired; comment acceptance remains |
| Review state / chat-run milestones | issue-review-state notification boundary | wired; exact caller coverage audit remains |
| Execution-control writes/run settlement | execution-control transaction helpers | wired; exact caller coverage audit remains |
| Agent deletion/membership reassignment, issue tree transitions, title/prefix | owner transaction | wired; representative acceptance remains |
| Workspace selection/binding, plugin/provider workspace, native recovery/reclaim, active watchdog, retry | owner transactions where uncovered by shared notifications | partially wired; remaining per-write audit required |
| Raw workspace cleanup/routes, conversation activation/settlement, queue paths | direct writes and adjacent owner notifications | exact coverage incomplete; not acceptance-ready |
| Imports/independent writers/replicas | supported service writes and startup reconstruction | full ingress contract audit remains |
Once a generation exists, the normal issue assignment authority remains its
owner. Assignment failure cancels that issue and finalizes the card's failed
ledger entry. The user's ordinary refresh action can retry it. Restart recovery
must not repeat an already completed generation. No new timer table, automatic
write replay, execution-authority exemption or sleep-safety relaxation is added.
Process-local notifications cover writes using the installed database owner. They do not notify an unrelated SQL writer or another server process; those supported paths need explicit ingress or reconstruction coverage. No claim of cross-replica delivery, host suspension, or fleet savings is made by the current tests.
## Supported producer coverage
## Acceptance evidence and remaining gates
All hints are registered before the relevant write within its owning transaction.
Nested service writes share outer commit ownership. Company-scoped hints batch
reactive reconciliation; ordinary execution writes without known scope retain
all existing execution consumers without sending an instance-wide card hint.
The original status-card PostgreSQL suite passed25/25. New real PostgreSQL tests exercise ordinary card compilation/writeback, source-change debounce, persisted assignment at a controlled provider boundary, result persistence, quiet SQL after completion, earlier/cancelled deadlines, nested outer commit/rollback and drain/resume. The provider boundary is simulated; no model runs. Additional race, restart, failure, active-hour/rate/budget persistence, multi-company and producer-class coverage remain before PR delivery.
| Producer | Notification boundary and coverage |
| --- | --- |
| Card create, configuration, archive/restore, delete, compile, query/summary result, evaluation claim | Status-card mutation transactions; stalled issue finalization signals before releasing the card claim |
| Experimental status-card setting | Instance experimental-settings transaction; full reconstruction is appropriate for this instance-wide setting |
| Company pause/archive/reactivate/delete | Company lifecycle/deletion transactions and budget-owned company hard-stop/resume transactions |
| Issue create/update/delete and label changes | Ordinary issue transactions; label deletion signals its known company before cascades; title and prefix changes signal their existing owner transactions |
| Issue checkout/adoption/release/reset and environment-selection recency | Existing issue transaction, or a transaction around the previously standalone write; company comes from the existing issue/run read |
| Comment create, edit, delete, tombstone, import and recency | Common comment insert; outer root comment transaction; queued-comment and Telegram edit transactions; existing metadata-upgrade path; remove/tombstone transactions |
| Issue import, comment import, document import | One notification per affected company in the existing batch transaction; no per-row notification lookup |
| Document create/update/restore/delete and search membership | Document owner transactions; only the existing company is passed, without copying search predicates into producers |
| Review, interaction and execution transitions | Existing issue-review-state and native-status-reassessment boundaries; scoped execution-control helpers; interaction recency touches own a transaction when called after settlement |
| Run admission, retries, monitors, watchdog/recovery and native settlement/reclaim | Existing scoped review/milestone/status/execution-control hooks; explicit notifications at the uncovered issue execution bind, legacy claim, monitor claim/release and review-participant recovery transaction |
| Agent deletion/access reassignment and project deletion/privacy cascades | Existing company-scoped owner transactions before foreign-key or recursive issue changes |
| Workspace selection/binding, plugin/provider workspace and cleanup/audit touches | Existing owner transactions, including standalone cleanup and audit recency writes now enclosed with their intent |
| Skill-test harness expiry and task-watchdog fingerprint touch | Existing owner mutation transaction; no feature policy is moved into the writer |
Shared deadline regressions reproduced and corrected a settled-write admission hold during drain. The correction releases only settled holds, retains an in-memory resume wake, and leaves ambiguous transaction intent fenced by the database coordinator. Unit coverage includes first write during drain, failure entering drain, preserved future deadlines, warm standby and ambiguous commit settlement.
The audit includes direct issue/comment mutations, raw recursive privacy updates,
bulk insert helpers, label/project/agent cascades, linked document search content
and timestamp-dependent search membership. Metadata-only writes that do not
change the fingerprint, matching fields or visibility do not create an evaluation
obligation: examples include run identity linkage, workspace IDs without a
recency change, document locks and source-trust receipts without searched content
changes. Routine and status-summary documents that have no issue-document link
do not change issue search membership.
This follows the existing [single-active-writer contract](../idle-sleep-safety.md).
Supported services, import/recovery writers, caller-owned transactions and startup
reconstruction participate. Independent SQL writers or another active server
process require explicit wake ingress; process-local hints do not establish
cross-replica notification delivery. The advisory lock supplies competing
scheduler exclusion, not cross-process event transport. Internal deadlines do
not establish a Cloud wake alarm or prove host/database suspension.
## Verification
Real PostgreSQL acceptance uses ordinary card create/compile and authorized
summarizer writeback. The assignment boundary is controlled: it persists a wake,
and the test explicitly performs the authorized result write; no model runs.
Checks cover empty/manual/reactive/disabled quiet SQL, first source change,
debounce and durable wake/result, earlier/cancelled deadlines, outer commit and
rollback, drain/resume, competing connections, busy locks, restart eligibility,
active hours/rate/budget gates, multiple companies, budget-owned company resume,
ignored activity, document/comment membership, relative query expiry including
an off-page candidate, failed assignment cleanup/manual retry and the persisted
five-minute internal-failure retry across restart/drain.
Shared coordinator regressions also cover warm standby, ambiguous transaction
intent, failure entering drain and different domain/coordinator gates. The paused
worker releases only settled holds and retains a future in-memory resume wake.
An expired saved deadline cannot schedule an immediate skipped-attempt loop.
Existing startup, status-card, company-search, execution and monitor suites cover
the production wiring and unchanged consumers. Exact commands and results belong
in the PR verification record; this document does not claim deployed savings.
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
@@ -28,12 +29,13 @@ import {
import { issueReadSqlCondition, projectReadSqlCondition } from "../services/authorization.js";
import { issueService } from "../services/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_COMPANY_SEARCH_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company search tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
`Skipping embedded Postgres company search tests on this host: ${("reason" in embeddedPostgresSupport ? embeddedPostgresSupport.reason : "unsupported environment")}`,
);
}
@@ -80,8 +82,11 @@ describeEmbeddedPostgres("companySearchService", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
db = createDb(tempDb.connectionString);
if (!externalDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
db = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 6 });
// External suites can reuse this dedicated database sequentially. Match
// the fresh embedded fixture before this suite creates its own rows.
if (externalDatabaseUrl) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
svc = companySearchService(db);
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
}, 20_000);
@@ -100,6 +105,7 @@ describeEmbeddedPostgres("companySearchService", () => {
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
@@ -1,3 +1,4 @@
import { statusCardEvaluationService, registerStatusCardEvaluationWork } from "../services/status-card-evaluation.js";
import { nativeHarnessBackupExpirationService } from "../services/native-runtime/native-harness-backup-expiration.js";
import { skillVersionRetentionService } from "../services/skill-version-retention.js";
import { approvalQueueSeedingService } from "../services/approval-queue-seeding-work.js";
@@ -527,6 +528,13 @@ vi.mock("../services/native-runtime/native-question-bridge.js", () => ({
validateNativeQuestionResponseInput: vi.fn(),
}));
vi.mock("../services/status-card-evaluation.js", () => ({
statusCardEvaluationService: vi.fn(() => ({ run: vi.fn(async () => {}), next: vi.fn(async () => null), changed: vi.fn() })),
registerStatusCardEvaluationWork: vi.fn((_coordinator, service, canRun) => ({
ready: canRun() ? service.run(new AbortController().signal) : Promise.resolve(), wake: vi.fn(), stop: vi.fn(async () => {}),
})),
}));
vi.mock("../services/secret-proposals.js", () => ({
createSecretProposalsService: vi.fn(() => ({
expiryDeadlines: vi.fn(async () => []),
@@ -790,6 +798,28 @@ describe("startServer feedback export wiring", () => {
expect(deliveryWorkErrors.filter(error => error.queue === DELIVERY_QUEUES.skillVersionRetention)).toEqual([]);
});
it.each([false, true])("registers status-card evaluation only with maintenance and never ticks it from the heartbeat loop (%s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled, heartbeatSchedulerIntervalMs: 1000 }));
let tick: (() => void) | undefined;
const timer = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, interval: number) => {
if (interval === 1000) tick = callback;
return 1 as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval);
try {
await startServer(); await settleStartupHousekeeping();
expect(registerStatusCardEvaluationWork).toHaveBeenCalledTimes(enabled ? 1 : 0);
expect(statusCardEvaluationService).toHaveBeenCalledTimes(enabled ? 1 : 0);
if (enabled) {
const service = vi.mocked(statusCardEvaluationService).mock.results.at(-1)!.value;
expect(service.run).toHaveBeenCalledOnce();
expect(tick).toBeDefined();
for (let i = 0; i < 3; i++) { tick!(); await settleStartupHousekeeping(); }
expect(service.run).toHaveBeenCalledOnce();
}
expect(deliveryWorkErrors.filter(error => error.queue === DELIVERY_QUEUES.statusCardEvaluation)).toEqual([]);
} finally { timer.mockRestore(); }
});
it.each([false, true])("registers response recovery and its shutdown subscription only when maintenance is enabled (%s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
await startServer(); await settleStartupHousekeeping();
@@ -1,7 +1,9 @@
import { budgetService } from "../services/budgets.js";
import { documentService } from "../services/documents.js";
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { activityLog, agentWakeupRequests, agents, companies, companyMemberships, createDb, documents,
import { activityLog, agentWakeupRequests, agents, approvals, budgetIncidents, budgetPolicies, costEvents, companies, companyMemberships, createDb, documents,
heartbeatRuns, instanceSettings, issueComments, issues, statusCards, statusCardUpdates, documentRevisions } from "@paperclipai/db";
import { companySearchQuerySchema, statusCardRefreshPolicySchema } from "@paperclipai/shared";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
@@ -22,6 +24,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
let draining = false;
let companyId: string, agentId: string;
const errors: unknown[] = [];
let compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"] });
const actor = { agentId: null, userId: "board-user" };
const policy = (mode: "manual" | "reactive" | "interval" = "reactive") => statusCardRefreshPolicySchema.parse({
mode, debounceSeconds: 60, intervalMinutes: 15,
@@ -29,7 +32,8 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
// Controlled provider boundary: assignment persists a queued wake, and tests
// explicitly run the authorized summarizer writeback. No model/provider runs.
const wakeup = vi.fn(async (id: string, options: { payload?: Record<string, unknown> | null; reason?: string | null }) => {
return db.insert(agentWakeupRequests).values({ companyId, agentId: id, source: "assignment",
const [agent] = await db.select({ companyId: agents.companyId }).from(agents).where(eq(agents.id, id));
return db.insert(agentWakeupRequests).values({ companyId: agent!.companyId, agentId: id, source: "assignment",
reason: options.reason, payload: options.payload, status: "queued" }).returning();
});
@@ -38,7 +42,8 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
}, 30_000);
beforeEach(async () => {
companyId = randomUUID(); agentId = randomUUID(); draining = false; errors.length = 0; wakeup.mockClear();
companyId = randomUUID(); agentId = randomUUID(); draining = false; errors.length = 0; wakeup.mockReset();
compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"] });
await db.insert(companies).values({ id: companyId, name: "Status", issuePrefix: `S${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Summarizer", role: "general", status: "idle", adapterType: "process", adapterConfig: {} });
await instanceSettingsService(db).updateExperimental({ enableStatusCards: true });
@@ -48,6 +53,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
await db.delete(budgetIncidents); await db.delete(budgetPolicies); await db.delete(approvals); await db.delete(costEvents);
await db.delete(agentWakeupRequests); await db.delete(statusCardUpdates); await db.delete(statusCards);
await db.delete(documentRevisions); await db.delete(documents); await db.delete(activityLog);
await db.delete(issueComments); await db.delete(issues); await db.delete(heartbeatRuns);
@@ -80,14 +86,14 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
await db.update(issues).set({ checkoutRunId: run!.id }).where(eq(issues.id, generationIssueId));
const writer = { agentId, runId: run!.id };
const cards = statusCardService(db);
if (compile) await cards.writeQuery(cardId, { queries: [companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"] })],
if (compile) await cards.writeQuery(cardId, { queries: [compiledQuery],
title: "Blockers", changeSummary: "Compiled", generationIssueId }, writer);
await cards.writeSummary(cardId, { markdown: "Current blockers", changeSummary: "Updated", generationIssueId }, writer);
await issueService(db).update(generationIssueId, { status: "done" });
}
async function card(mode: "manual" | "reactive" | "interval" = "reactive") {
async function card(mode: "manual" | "reactive" | "interval" = "reactive", refreshPolicy = policy(mode)) {
const cards = statusCardService(db);
const created = await cards.create(companyId, { interestPrompt: "Blockers", titlePinned: false, agentId, refreshPolicy: policy(mode) }, actor);
const created = await cards.create(companyId, { interestPrompt: "Blockers", titlePinned: false, agentId, refreshPolicy }, actor);
const compile = await cards.requestCompile(created.id, actor);
await writeback(created.id, compile.generatingIssue.id, true);
return cards.getById(created.id).then(row => row!);
@@ -131,6 +137,19 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
expect(wakeup).not.toHaveBeenCalled(); expect(errors).toEqual([]);
});
it("restores overdue debt after the feature is disabled across its saved deadline", async () => {
await start(); const created = await card(); await quiet();
await issueService(db).create(companyId, { title: "Disabled blocker", status: "blocked", priority: "medium" });
await vi.waitFor(async () => expect((await statusCardService(db).getById(created.id))?.pendingChangeCount).toBeGreaterThan(0));
const pending = (await statusCardService(db).getById(created.id))!;
await instanceSettingsService(db).updateExperimental({ enableStatusCards: false }); await quiet();
expect(wakeup).not.toHaveBeenCalled();
expect((await statusCardService(db).getById(created.id))!.nextEvalAt).toEqual(pending.nextEvalAt);
await instanceSettingsService(db).updateExperimental({ enableStatusCards: true });
await vi.waitFor(() => expect(wakeup).toHaveBeenCalledOnce());
await writeback(created.id, (await statusCardService(db).getById(created.id))!.generatingIssueId!); await quiet();
});
it("does not react to rolled-back or nested uncommitted card configuration", async () => {
await start(); const created = await card("manual"); await quiet();
await expect(db.transaction(async tx => {
@@ -169,6 +188,184 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
.toHaveLength(1);
});
it.each(["debounce", "hours", "rate", "budget"] as const)("restores pending %s eligibility after restart without advancing it or acting early", async gate => {
vi.setSystemTime(new Date("2026-11-02T18:00:00Z"));
const refreshPolicy = { ...policy(), ...(gate === "hours" ? { activeHours: { start: "09:00", end: "17:00", timezone: "UTC" } } : {}),
...(gate === "rate" ? { maxUpdatesPerHour: 1 } : {}), ...(gate === "budget" ? { dailyTokenCap: 100 } : {}) };
const created = await card("reactive", refreshPolicy);
if (gate === "rate" || gate === "budget") await db.insert(statusCardUpdates).values({ cardId: created.id, kind: "full", trigger: "reactive",
status: "ok", startedAt: new Date(Date.now() - (gate === "rate" ? 30 * 60_000 : 60_000)), finishedAt: new Date(), inputTokens: gate === "budget" ? 100 : 0 });
await issueService(db).create(companyId, { title: "Saved blocker", status: "blocked", priority: "medium", createdByUserId: "board-user" });
const service = statusCardEvaluationService(db, { wakeup });
await service.run(new AbortController().signal);
const pending = (await statusCardService(db).getById(created.id))!;
expect(pending.pendingChangeCount).toBeGreaterThan(0); expect(pending.nextEvalAt!.getTime()).toBeGreaterThan(Date.now());
const restarted = statusCardEvaluationService(db, { wakeup });
await restarted.run(new AbortController().signal);
expect(await restarted.next()).toBe(pending.nextEvalAt!.getTime());
expect((await statusCardService(db).getById(created.id))!.nextEvalAt).toEqual(pending.nextEvalAt);
vi.setSystemTime(pending.nextEvalAt!.getTime() - 1); await restarted.run(new AbortController().signal);
expect(wakeup).not.toHaveBeenCalled();
vi.setSystemTime(pending.nextEvalAt!); await restarted.run(new AbortController().signal);
expect(wakeup).toHaveBeenCalledOnce();
});
it("unions startup reconstruction with a scoped source change in another company", async () => {
const firstCompany = companyId;
const first = await card();
await issueService(db).create(companyId, { title: "First blocker", status: "blocked", priority: "medium" });
companyId = randomUUID(); agentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Other", issuePrefix: `X${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Other summarizer", role: "general", status: "idle", adapterType: "process", adapterConfig: {} });
const second = await card();
await issueService(db).create(companyId, { title: "Second blocker", status: "blocked", priority: "medium" });
const service = statusCardEvaluationService(db, { wakeup });
service.changed(new Set([`changes:${companyId}`]));
await service.run(new AbortController().signal);
const cards = statusCardService(db);
expect((await cards.getById(first.id))?.companyId).toBe(firstCompany);
expect((await cards.getById(first.id))?.pendingChangeCount).toBeGreaterThan(0);
expect((await cards.getById(second.id))?.pendingChangeCount).toBeGreaterThan(0);
await vi.advanceTimersByTimeAsync(60_000); await service.run(new AbortController().signal);
expect(wakeup).toHaveBeenCalledTimes(2);
expect(new Set(await db.select().from(agentWakeupRequests).then(rows => rows.map(row => row.companyId))))
.toEqual(new Set([firstCompany, companyId]));
});
it("keeps ignored issue activity quiet and reacts to human comments and document search membership", async () => {
await start(); await card(); await quiet();
await issueService(db).create(companyId, { title: "Ignored todo", status: "todo", priority: "medium" });
await quiet(); expect(wakeup).not.toHaveBeenCalled();
compiledQuery = companySearchQuerySchema.parse({ scope: "all", q: "launch" });
const searchCard = await card(); await quiet();
const target = await issueService(db).create(companyId, { title: "Ordinary task", status: "todo", priority: "medium" });
await quiet();
await documentService(db).upsertIssueDocument({ issueId: target.id, key: "plan", format: "markdown", body: "launch evidence", createdByUserId: "board-user" });
await vi.waitFor(async () => expect((await statusCardService(db).getById(searchCard.id))?.pendingChangeCount).toBeGreaterThan(0));
await vi.advanceTimersByTimeAsync(60_000); await settled(); expect(wakeup).toHaveBeenCalledOnce();
await writeback(searchCard.id, (await statusCardService(db).getById(searchCard.id))!.generatingIssueId!); await quiet();
await issueService(db).addComment(target.id, "A human launch update", { userId: "board-user" });
await vi.waitFor(async () => expect((await statusCardService(db).getById(searchCard.id))?.pendingChangeCount).toBeGreaterThan(0));
});
it("retains relative query membership expiry across restart and emits the eventual removal", async () => {
const now = new Date();
const target = await issueService(db).create(companyId, { title: "Expiring blocker", status: "blocked", priority: "medium" });
await db.update(issues).set({ updatedAt: now }).where(eq(issues.id, target.id));
compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"], updatedWithin: "1h" });
const created = await card();
const expires = now.getTime() + 3_600_001;
expect(created.nextEvalAt?.getTime()).toBe(expires);
const restarted = statusCardEvaluationService(db, { wakeup });
await restarted.run(new AbortController().signal);
expect(await restarted.next()).toBe(expires);
vi.setSystemTime(expires - 1); await restarted.run(new AbortController().signal); expect(wakeup).not.toHaveBeenCalled();
vi.setSystemTime(expires); await restarted.run(new AbortController().signal);
const pending = (await statusCardService(db).getById(created.id))!;
expect(pending.pendingChangeCount).toBe(1); expect(pending.nextEvalAt!.getTime()).toBe(expires + 60_000);
vi.setSystemTime(pending.nextEvalAt!); await restarted.run(new AbortController().signal);
expect(wakeup).toHaveBeenCalledOnce();
});
it("uses expiry of an off-page candidate to refresh offset-ranked query membership", async () => {
const now = new Date();
const tasks = await Promise.all((["critical", "high", "medium"] as const).map(priority => issueService(db).create(companyId, {
title: `${priority} blocker`, priority, status: "blocked",
})));
await db.update(issues).set({ updatedAt: new Date(now.getTime() - 59 * 60_000) }).where(eq(issues.id, tasks[0]!.id));
for (const task of tasks.slice(1)) await db.update(issues).set({ updatedAt: now }).where(eq(issues.id, task.id));
compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"], updatedWithin: "1h", sort: "priority", limit: 1, offset: 1 });
const created = await card();
expect(Object.keys(created.fingerprint!)).toEqual([tasks[1]!.id]);
const expires = now.getTime() + 60_001;
expect(created.nextEvalAt?.getTime()).toBe(expires);
const service = statusCardEvaluationService(db, { wakeup });
vi.setSystemTime(expires); await service.run(new AbortController().signal);
const pending = (await statusCardService(db).getById(created.id))!;
expect(pending.pendingChangeCount).toBe(2); expect(wakeup).not.toHaveBeenCalled();
vi.setSystemTime(pending.nextEvalAt!); await service.run(new AbortController().signal);
expect(wakeup).toHaveBeenCalledOnce();
const [update] = await db.select().from(statusCardUpdates).where(eq(statusCardUpdates.generationIssueId, (await statusCardService(db).getById(created.id))!.generatingIssueId!));
expect(new Set(update!.changes.map(change => change.issueId))).toEqual(new Set([tasks[1]!.id, tasks[2]!.id]));
});
it("retains a scoped retry while another evaluator owns the card lock", async () => {
const created = await card();
await issueService(db).create(companyId, { title: "Locked blocker", status: "blocked", priority: "medium" });
const acquired = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
const holder = db.transaction(async tx => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${created.id}, 0))`);
acquired.resolve(); await release.promise;
});
await acquired.promise;
const service = statusCardEvaluationService(db, { wakeup });
service.changed(new Set([`changes:${companyId}`]));
try {
await service.run(new AbortController().signal);
expect(await service.next()).toBeGreaterThan(Date.now());
expect((await statusCardService(db).getById(created.id))!.nextEvalAt).toBeNull();
expect(wakeup).not.toHaveBeenCalled();
} finally { release.resolve(); await holder; }
await vi.advanceTimersByTimeAsync(30_000); await service.run(new AbortController().signal);
const pending = (await statusCardService(db).getById(created.id))!;
expect(pending.pendingChangeCount).toBeGreaterThan(0);
vi.setSystemTime(pending.nextEvalAt!); await service.run(new AbortController().signal);
expect(wakeup).toHaveBeenCalledOnce();
});
it("retains the durable five-minute retry after evaluation failure across restart and drain", async () => {
const created = await card();
await db.update(statusCards).set({ queries: [{ scope: "invalid" } as never], nextEvalAt: new Date() }).where(eq(statusCards.id, created.id));
const service = statusCardEvaluationService(db, { wakeup });
await service.run(new AbortController().signal);
const failed = (await statusCardService(db).getById(created.id))!;
const retryAt = failed.nextEvalAt!.getTime();
expect(retryAt).toBeGreaterThan(Date.now() + 299_000); expect(wakeup).not.toHaveBeenCalled();
await start(); expect(coordinator!.nextWakeAt()).toBe(retryAt);
draining = true; await vi.advanceTimersByTimeAsync(5 * 60_000); await settled(); expect(wakeup).not.toHaveBeenCalled();
// Repair the controlled malformed stored-query fault; no tested result is precreated.
await db.update(statusCards).set({ queries: [compiledQuery] }).where(eq(statusCards.id, created.id));
draining = false; await vi.advanceTimersByTimeAsync(30_000); await quiet();
expect((await statusCardService(db).getById(created.id))!.nextEvalAt).toBeNull();
});
it("cancels failed assignments without rerunning a completed generation and settles after retry", async () => {
await start(); const created = await card(); await quiet();
wakeup.mockRejectedValueOnce(new Error("assignment unavailable"));
await issueService(db).create(companyId, { title: "Assignment blocker", status: "blocked", priority: "medium" });
await vi.waitFor(async () => expect((await statusCardService(db).getById(created.id))?.pendingChangeCount).toBeGreaterThan(0));
await vi.advanceTimersByTimeAsync(60_000); await settled();
const failed = (await statusCardService(db).getById(created.id))!;
expect(failed).toMatchObject({ state: "error", generatingIssueId: null, nextEvalAt: null });
expect(errors.length).toBeGreaterThan(0);
const history = await db.select().from(statusCardUpdates).where(eq(statusCardUpdates.cardId, created.id));
const ledger = history.find(row => row.status === "failed")!;
expect(ledger.finishedAt).toBeInstanceOf(Date);
expect((await db.select().from(issues).where(eq(issues.id, ledger.generationIssueId!)))[0]?.status).toBe("cancelled");
const count = wakeup.mock.calls.length; await quiet(); expect(wakeup).toHaveBeenCalledTimes(count);
// An explicit ordinary refresh is still the user's supported retry action.
const retry = await statusCardService(db).requestRefresh(created.id, { trigger: "manual", actor });
expect(retry.generatingIssue).toBeTruthy(); await writeback(created.id, retry.generatingIssue!.id); await quiet();
expect((await statusCardService(db).getById(created.id))!.state).toBe("active");
});
it("budget-owned company resume reactivates reactive work committed while paused", async () => {
await start(); const created = await card(); await quiet();
const budgets = budgetService(db);
await budgets.upsertPolicy(companyId, { scopeType: "company", scopeId: companyId, amount: 100, hardStopEnabled: true, notifyEnabled: false }, "board-user");
const [cost] = await db.insert(costEvents).values({ companyId, agentId, provider: "openai", biller: "openai", billingType: "metered_api", model: "test",
costCents: 200, occurredAt: new Date() }).returning();
await budgets.evaluateCostEvent(cost!);
expect((await db.select().from(companies).where(eq(companies.id, companyId)))[0]?.status).toBe("paused");
await quiet();
await issueService(db).create(companyId, { title: "Paused blocker", status: "blocked", priority: "medium" }); await quiet();
expect(wakeup).not.toHaveBeenCalled();
await budgets.upsertPolicy(companyId, { scopeType: "company", scopeId: companyId, amount: 1000 }, "board-user");
expect((await db.select().from(companies).where(eq(companies.id, companyId)))[0]?.status).toBe("active");
await vi.waitFor(async () => expect((await statusCardService(db).getById(created.id))?.pendingChangeCount).toBeGreaterThan(0));
await vi.advanceTimersByTimeAsync(60_000); await settled(); expect(wakeup).toHaveBeenCalledOnce();
});
it("preserves the first write during drain until resume", async () => {
await start(); const created = await card("manual"); await quiet();
draining = true;
@@ -0,0 +1,31 @@
import { expect, it, vi } from "vitest";
import { installDatabaseWorkSignals, subscribeDatabaseWork } from "../../../packages/db/src/work-signals.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import { notifyExecutionRunWork, writeExecutionControl } from "../services/execution-control-notifications.js";
it.each([false, true])("keeps execution queue consumers intact and emits only a known status-card company scope (%s)", async scoped => {
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []), transaction: async (fn: (tx: object) => Promise<unknown>) =>
fn({ execute: async () => [{ xid: "1" }] }) });
const queues = [DELIVERY_QUEUES.executionStatus, DELIVERY_QUEUES.executionFinalization, DELIVERY_QUEUES.executionReplacement,
DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.issueMonitors];
const settled: Array<{ queue: string; hints?: string[] }> = [];
const unsubscribe = [...queues, DELIVERY_QUEUES.statusCardEvaluation, DELIVERY_QUEUES.workspaceRecovery].map(queue => subscribeDatabaseWork(owner, queue, (event, hints) => {
if (event === "settled") settled.push({ queue, hints: hints ? [...hints] : undefined });
}));
try {
const scope = scoped ? { companyId: "changed-company", runId: "changed-run" } : undefined;
await owner.transaction(async tx => {
await notifyExecutionRunWork(tx, scope);
expect(settled).toEqual([]);
});
expect(new Set(settled.map(row => row.queue))).toEqual(new Set([...queues, ...(scoped ? [DELIVERY_QUEUES.statusCardEvaluation] : [])]));
expect(settled.find(row => row.queue === DELIVERY_QUEUES.statusCardEvaluation)?.hints)
.toEqual(scoped ? ["changes:changed-company"] : undefined);
settled.length = 0;
const write = vi.fn(async () => "written");
expect(await writeExecutionControl(owner as never, DELIVERY_QUEUES.workspaceRecovery, write, scope)).toBe("written");
expect(write).toHaveBeenCalledOnce();
expect(new Set(settled.map(row => row.queue))).toEqual(new Set([DELIVERY_QUEUES.issueMonitors, DELIVERY_QUEUES.workspaceRecovery,
...(scoped ? [DELIVERY_QUEUES.statusCardEvaluation] : [])]));
} finally { for (const stop of unsubscribe) stop(); }
});
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "../../../services/status-card-notifications.js";
import { notifyExternalConversationSettlement } from "../../../services/external-conversation-settlement-notifications.js";
import { notifyNativeStatusReassessment } from "../../../services/native-status-reassessment-notifications.js";
import { notifyUnsafeWorkspaceExportRecovery } from "../../../services/unsafe-workspace-export-notifications.js";
@@ -610,6 +611,7 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru
},
async queueReviewParticipantRecoveryRun({ companyId, issue, finishingRun, recoveryAgent, sessionBefore, now }) {
await notifyStatusCardIssueChange(tx, companyId);
const executionState = parseIssueExecutionState(issue.executionState);
const wakeupRequest = await tx
.insert(agentWakeupRequests)
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "../../../services/status-card-notifications.js";
import { notifyExternalAgentSettlement } from "../../external-agents/index.js";
import { notifyNativeStatusReassessment } from "../../../services/native-status-reassessment-notifications.js";
import { notifyUnsafeWorkspaceExportRecovery } from "../../../services/unsafe-workspace-export-notifications.js";
@@ -55,6 +56,7 @@ export type QueuedCommentQueuePostgresAdapterDeps = {
function buildTransaction(tx: Db, companyId: string, issueId: string, deps: QueuedCommentQueuePostgresAdapterDeps): QueuedCommentQueueTransaction {
return {
async updateCommentBody({ issueId, commentId, body, updatedAt }) {
await notifyStatusCardIssueChange(tx, companyId);
const updated = await tx
.update(issueComments)
.set({ body, updatedAt })
@@ -65,6 +67,7 @@ function buildTransaction(tx: Db, companyId: string, issueId: string, deps: Queu
},
async touchIssueUpdatedAt({ issueId, updatedAt }) {
await notifyStatusCardIssueChange(tx, companyId);
await tx.update(issues).set({ updatedAt }).where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)));
},
+5 -1
View File
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "../services/status-card-notifications.js";
import { removePluginWorkspace } from "../services/plugin-workspaces.js";
import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privacy.js";
import { canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition } from "../services/authorization.js";
@@ -1245,7 +1246,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
await runtimeLeases.release({ executionWorkspaceId: existing.id, force: true });
if (existing.mode === "shared_workspace") {
await db
await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, existing.companyId);
await tx
.update(issues)
.set({
executionWorkspaceId: null,
@@ -1257,6 +1260,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
eq(issues.executionWorkspaceId, existing.id),
),
);
});
}
try {
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { FAST_RESPONSE_AGENT_GUIDANCE, fastResponseHistoryBody } from "@paperclipai/shared";
@@ -269,6 +270,7 @@ export async function prepareConversationTurn(
context.conversationReplayThroughCommentId = replayThrough?.id ?? commentId;
}
}
await notifyStatusCardIssueChange(tx, issue.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
await tx
@@ -389,6 +391,7 @@ export async function settleConversationTurn(
issue.conversationState === conversationState
)
return true;
await notifyStatusCardIssueChange(tx, issue.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
if (status === "in_review") await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: issue.companyId, issueId: issue.id });
+3
View File
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyApprovalWork } from "./approval-work-notifications.js";
import { notifyCompanyRetentionWork } from "./retention-work-notifications.js";
import { addCents, compareCents, normalizeCents } from "@paperclipai/shared";
@@ -301,6 +302,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
const scope = await resolveScopeRecord(db, "company", policy.scopeId);
if (scope.paused) return;
await notifyCompanyRetentionWork(db, policy.companyId);
await notifyStatusCardIssueChange(db, policy.companyId);
await notifyDeliveryWork(db, DELIVERY_QUEUES.agentTimers);
await notifyDeliveryWork(db, DELIVERY_QUEUES.routineSchedules);
await notifyDeliveryWork(db, DELIVERY_QUEUES.issueMonitors);
@@ -361,6 +363,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
const scope = await resolveScopeRecord(db, "company", policy.scopeId);
if (scope.pauseReason !== "budget") return;
await notifyCompanyRetentionWork(db, policy.companyId);
await notifyStatusCardIssueChange(db, policy.companyId);
await notifyDeliveryWork(db, DELIVERY_QUEUES.agentTimers);
await notifyDeliveryWork(db, DELIVERY_QUEUES.routineSchedules);
await notifyDeliveryWork(db, DELIVERY_QUEUES.issueMonitors);
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "../../status-card-notifications.js";
import { createProviderEffects, type ProviderEffectTarget } from "../provider-effects.js";
import type { EndpointRow, RuntimeContext, InboundRuntimeContext } from "../runtime-types.js";
@@ -82,6 +83,7 @@ export function createTelegramAttachmentFailure(db: Db, options: TelegramAttachm
// Paperclip-visible failure. The message link stays authoritative for
// exact provider redelivery, while no agent wakeup is enqueued for an
// empty turn that contains no usable bytes.
await notifyStatusCardIssueChange(tx, input.endpoint.companyId);
await tx
.update(issueComments)
.set({ body: visibleFailure, updatedAt: new Date() })
+14 -1
View File
@@ -548,7 +548,7 @@ export function companySearchService(db: Db) {
search: async (
companyId: string,
query: CompanySearchQuery,
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean> },
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean>; onNextMembershipChange?: (at: Date | null) => void },
): Promise<CompanySearchResponse> => {
const taskSearch = parseTaskSearch(query.q);
const normalizedQuery = taskSearch.normalizedQuery;
@@ -559,6 +559,7 @@ export function companySearchService(db: Db) {
const limit = query.limit;
const offset = query.offset;
if (!hasSearchText && !issueOnlyFiltersActive(query)) {
options?.onNextMembershipChange?.(null);
return {
query: query.q,
normalizedQuery,
@@ -727,6 +728,13 @@ export function companySearchService(db: Db) {
}
}
const membershipWindow = companySearchUpdatedWithinMs(query.updatedWithin);
if (options?.onNextMembershipChange && membershipWindow !== null && scopeIncludesIssues(scope)) {
// Includes rows outside the visible page: their expiry can move an
// offset/priority-ranked result into the watched set without a write.
branches.push(sql`SELECT 'membership-expiry' AS kind, min(m.updated_at)::text AS value,
0::int AS count ${countTail} FROM matched m ${branchWhere([scopeCond, ...facetsAll])}`);
}
const resultRows = await db.execute(sql`
${taskSearchCtes(companyId, taskSearch, scope !== "issues", and(...issueFilters), options?.issueReadCondition)}
${sql.join(branches, sql` UNION ALL `)}
@@ -738,6 +746,7 @@ export function companySearchService(db: Db) {
totals: { current: 0, unfiltered: 0, omit: {} },
};
const issueRowsRaw: IssueSearchRow[] = [];
let nextMembershipChange: Date | null = null;
for (const row of resultRows) {
if (row.kind === "result") {
issueRowsRaw.push({
@@ -762,6 +771,9 @@ export function companySearchService(db: Db) {
});
continue;
}
if (row.kind === "membership-expiry" && row.value && membershipWindow !== null) {
nextMembershipChange = new Date(new Date(row.value).getTime() + membershipWindow + 1);
}
const count = Number(row.count ?? 0);
if (row.kind === "type:issue") aggregates.typeCounts.issue = count;
else if (row.kind === "type:comment") aggregates.typeCounts.comment = count;
@@ -782,6 +794,7 @@ export function companySearchService(db: Db) {
aggregates.totals.omit[row.kind.slice("total:omit:".length) as CompanySearchIssueFilterKey] = count;
}
}
options?.onNextMembershipChange?.(nextMembershipChange);
return { rows: await enrichIssueSnippets(issueRowsRaw), aggregates };
}
+5
View File
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -238,6 +239,7 @@ export function documentService(db: Db) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await db.transaction(async (tx) => {
await notifyStatusCardIssueChange(tx, issue.companyId);
const now = new Date();
const existing = await tx
.select({
@@ -592,6 +594,7 @@ export function documentService(db: Db) {
});
}
await db.transaction(async (tx) => {
for (const companyId of new Set(rows.map(row => row.companyId))) await notifyStatusCardIssueChange(tx, companyId);
await insertRowsInChunks(tx, documents, documentRows);
await insertRowsInChunks(tx, documentRevisions, revisionRows);
await insertRowsInChunks(tx, issueDocuments, linkRows);
@@ -662,6 +665,7 @@ export function documentService(db: Db) {
})
.returning();
await notifyStatusCardIssueChange(tx, existing.companyId);
await tx
.update(documents)
.set({
@@ -818,6 +822,7 @@ export function documentService(db: Db) {
}
await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id));
await notifyStatusCardIssueChange(tx, existing.companyId);
await tx.delete(documents).where(eq(documents.id, existing.id));
return {
+4 -1
View File
@@ -2530,13 +2530,16 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
forceWorktreeRemoval: false,
});
if (cleanup.cleaned && workspace.mode === "shared_workspace") {
await db
await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, workspace.companyId);
await tx
.update(issues)
.set({ executionWorkspaceId: null, updatedAt: now() })
.where(and(
eq(issues.companyId, workspace.companyId),
eq(issues.executionWorkspaceId, workspace.id),
));
});
}
const cleanupReason = [ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON, ...cleanup.warnings].join(" | ");
if (!cleanup.cleaned || cleanup.warnings.length > 0) {
+3
View File
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js";
import { notifyNativeStatusReassessment } from "../native-status-reassessment-notifications.js";
@@ -1267,6 +1268,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
}
async function bindClaimedIssueExecution(tx: Db, ownsIssue: boolean, claimedRun: typeof heartbeatRuns.$inferSelect | null | undefined) {
if (!claimedRun || !issueId || !ownsIssue) return;
await notifyStatusCardIssueChange(tx, run.companyId);
await tx.update(issues).set({
executionRunId: claimedRun.id,
executionAgentNameKey: normalizeAgentNameKey(agent.name),
@@ -3758,6 +3760,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
.from(agents)
.where(eq(agents.id, legacyRun.agentId))
.then((rows) => rows[0] ?? null);
await notifyStatusCardIssueChange(tx, issue.companyId);
await tx
.update(issues)
.set({
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
import { notifyIssueReviewState } from "../issue-review-state-notifications.js";
import { insertIssueComment } from "../issue-comment-writes.js";
@@ -595,6 +596,7 @@ export function createHeartbeatScheduling(
try {
await db.transaction(async (tx) => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyStatusCardIssueChange(tx, claimed.companyId);
const released = await tx.update(issues)
.set({ monitorWakeRequestedAt: null, updatedAt: input.now })
.where(issueMonitorClaimCondition(claimed)).returning({ id: issues.id });
@@ -628,6 +630,7 @@ export function createHeartbeatScheduling(
// failed, and never clobber a monitor that was replaced in the meantime.
await db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyStatusCardIssueChange(tx, claimed.companyId);
await tx.update(issues).set({ monitorWakeRequestedAt: null, updatedAt: input.now })
.where(issueMonitorClaimCondition(claimed));
});
@@ -827,6 +830,7 @@ export function createHeartbeatScheduling(
}
const consumed = await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, claimed.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
return tx.update(issues)
.set({
@@ -977,6 +981,7 @@ export function createHeartbeatScheduling(
const staleClaimThreshold = new Date(now.getTime() - 5 * 60 * 1000);
const claimed = await db.transaction(async (tx) => {
await notifyStatusCardIssueChange(tx, issue.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
const [updated] = await tx
.update(issues)
@@ -1050,6 +1055,7 @@ export function createHeartbeatScheduling(
for (const due of dueMonitors) {
if (!canContinue()) break;
const claimed = await db.transaction(async (tx) => {
await notifyStatusCardIssueChange(tx, due.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
const [updated] = await tx
.update(issues)
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
import { notifyIssueReviewState } from "./issue-review-state-notifications.js";
import { notifyChatConfirmationResolution } from "./chat-confirmation-notifications.js";
@@ -376,7 +377,7 @@ type ResolvedInteractionResult = {
};
type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect;
type IssueTouchDb = Pick<Db, "update">;
type IssueTouchDb = Pick<Db, "update" | "transaction">;
function isNativeCompletionReview(
row: Pick<IssueThreadInteractionRow, "kind" | "payload">,
@@ -801,11 +802,11 @@ function hydrateInteraction(
}
}
async function touchIssue(db: IssueTouchDb, issueId: string) {
await db
.update(issues)
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));
async function touchIssue(db: IssueTouchDb, issueId: string, companyId: string) {
await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, companyId);
await tx.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, issueId));
});
}
function isTerminalIssueStatus(status: string) {
@@ -2013,7 +2014,7 @@ async function expireStaleRequestConfirmationTarget(
"Interaction has already been resolved",
);
}
await touchIssue(tx, args.row.issueId);
await touchIssue(tx, args.row.issueId, args.row.companyId);
const expired = hydrateInteraction(updated);
await enqueueTerminalIssueInteractionChatPublications(tx, expired);
return expired;
@@ -2468,7 +2469,7 @@ export function issueThreadInteractionService(
};
}
} else {
await touchIssue(tx, args.issue.id);
await touchIssue(tx, args.issue.id, args.issue.companyId);
}
// Snapshot the continuation target only after every lifecycle mutation
@@ -2682,7 +2683,7 @@ export function issueThreadInteractionService(
tx,
);
} else {
await touchIssue(tx, args.issue.id);
await touchIssue(tx, args.issue.id, args.issue.companyId);
}
await enqueueTerminalIssueInteractionChatPublications(
tx as unknown as Db,
@@ -2864,7 +2865,7 @@ export function issueThreadInteractionService(
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(previous));
}
}
await touchIssue(tx, issue.id);
await touchIssue(tx, issue.id, issue.companyId);
inserted = true;
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(row));
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
@@ -2908,7 +2909,7 @@ export function issueThreadInteractionService(
)
.returning();
if (!updated) throw interactionAlreadyResolvedError();
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
return hydrateInteraction(updated) as ConnectionIntentInteraction;
},
resolveConnectionIntent: async (
@@ -2972,7 +2973,7 @@ export function issueThreadInteractionService(
return row;
});
if (!updated) throw interactionAlreadyResolvedError();
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
const interaction = hydrateInteraction(
updated,
) as ConnectionIntentInteraction;
@@ -3351,7 +3352,7 @@ export function issueThreadInteractionService(
})
.where(eq(issueThreadInteractions.id, current.id))
.returning();
await touchIssue(tx, issue.id);
await touchIssue(tx, issue.id, issue.companyId);
return row;
});
return hydrateInteraction(updated);
@@ -3414,10 +3415,8 @@ export function issueThreadInteractionService(
if (updated) cancelled.push(updated);
}
for (const issueId of new Set(
cancelled.map((interaction) => interaction.issueId),
)) {
await touchIssue(db, issueId);
for (const [issueId, companyId] of new Map(cancelled.map(interaction => [interaction.issueId, interaction.companyId]))) {
await touchIssue(db, issueId, companyId);
}
await emitResolvedInteractionsTelemetry(db, cancelled);
return cancelled;
@@ -3506,10 +3505,8 @@ export function issueThreadInteractionService(
}
if (expired.length > 0) {
for (const issueId of new Set(
expired.map((interaction) => interaction.issueId),
)) {
await touchIssue(db, issueId);
for (const [issueId, companyId] of new Map(expired.map(interaction => [interaction.issueId, interaction.companyId]))) {
await touchIssue(db, issueId, companyId);
}
await emitResolvedInteractionsTelemetry(db, expired);
}
@@ -4052,7 +4049,7 @@ export function issueThreadInteractionService(
return interaction;
}
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
if (superseded.length > 0) {
await emitResolvedInteractionsTelemetry(
db,
@@ -4357,7 +4354,7 @@ export function issueThreadInteractionService(
.where(eq(issueThreadInteractions.id, interactionId))
.returning();
await touchIssue(tx, issue.id);
await touchIssue(tx, issue.id, issue.companyId);
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
current.status = updated.status;
@@ -4549,7 +4546,7 @@ export function issueThreadInteractionService(
throw interactionAlreadyResolvedError();
}
await touchIssue(tx, issue.id);
await touchIssue(tx, issue.id, issue.companyId);
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
if (complete) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
return {
@@ -4622,7 +4619,7 @@ export function issueThreadInteractionService(
throw interactionAlreadyResolvedError();
}
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
const rejected = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, rejected);
return rejected;
@@ -4720,7 +4717,7 @@ export function issueThreadInteractionService(
}
if (expired.length > 0) {
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
await emitResolvedInteractionsTelemetry(db, expired);
}
return expired;
@@ -4952,7 +4949,7 @@ export function issueThreadInteractionService(
}
if (expired.length > 0) {
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
await emitResolvedInteractionsTelemetry(db, expired);
}
return expired;
@@ -5049,7 +5046,7 @@ export function issueThreadInteractionService(
}
if (expired.length > 0) {
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
await emitResolvedInteractionsTelemetry(db, expired);
}
return expired;
@@ -5189,7 +5186,7 @@ export function issueThreadInteractionService(
if (updated) expired.push(hydrateInteraction(updated));
}
if (expired.length > 0) {
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
await emitResolvedInteractionsTelemetry(db, expired);
}
return expired;
@@ -5294,7 +5291,7 @@ export function issueThreadInteractionService(
return row;
});
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
const withdrawn = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, withdrawn);
return withdrawn;
@@ -5417,7 +5414,7 @@ export function issueThreadInteractionService(
return row;
});
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
const answered = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, answered);
return answered;
@@ -5553,7 +5550,7 @@ export function issueThreadInteractionService(
return row;
});
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
const skipped = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, skipped);
return skipped;
@@ -5630,7 +5627,7 @@ export function issueThreadInteractionService(
return row;
});
await touchIssue(db, issue.id);
await touchIssue(db, issue.id, issue.companyId);
const cancelled = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, cancelled);
return cancelled;
+24 -10
View File
@@ -7730,6 +7730,7 @@ export function issueService(db: Db) {
}
const now = new Date();
await notifyStatusCardIssueChange(tx, lockedIssue.companyId);
const adopted = await tx
.update(issues)
.set({
@@ -7783,13 +7784,14 @@ export function issueService(db: Db) {
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`,
);
const actorRun = await tx
.select({ status: heartbeatRuns.status })
.select({ status: heartbeatRuns.status, companyId: heartbeatRuns.companyId })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.actorRunId))
.then((rows) => rows[0] ?? null);
if (!actorRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status))
return null;
await notifyStatusCardIssueChange(tx, actorRun.companyId);
const now = new Date();
const adopted = await tx
.update(issues)
@@ -10892,6 +10894,7 @@ export function issueService(db: Db) {
}
}
await notifyStatusCardIssueChange(tx, companyId);
await insertRowsInChunks(tx, issues, issueRows);
await insertRowsInChunks(tx, issueLabels, labelRows);
});
@@ -10908,6 +10911,7 @@ export function issueService(db: Db) {
const censorUsernameInLogs = (await instanceSettings.getGeneral())
.censorUsernameInLogs;
await db.transaction(async (tx) => {
for (const companyId of new Set(rows.map(row => row.companyId))) await notifyStatusCardIssueChange(tx, companyId);
const commentRows = rows.map((row) => {
const createdAt = row.createdAt ? new Date(row.createdAt) : null;
return {
@@ -11922,7 +11926,9 @@ export function issueService(db: Db) {
);
if (settings?.environmentId !== environmentId) continue;
await db
await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, companyId);
await tx
.update(issues)
.set({
executionWorkspaceSettings: {
@@ -11932,6 +11938,7 @@ export function issueService(db: Db) {
updatedAt: new Date(),
})
.where(eq(issues.id, row.id));
});
cleared += 1;
}
@@ -12088,6 +12095,7 @@ export function issueService(db: Db) {
)
: isNull(issues.executionRunId);
const updated = await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
return tx
@@ -12140,7 +12148,9 @@ export function issueService(db: Db) {
current.executionRunId === checkoutRunId) &&
checkoutRunId
) {
const adopted = await db
const adopted = await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
return tx
.update(issues)
.set({
checkoutRunId,
@@ -12161,6 +12171,7 @@ export function issueService(db: Db) {
)
.returning()
.then((rows) => rows[0] ?? null);
});
if (adopted) return adopted;
}
@@ -12217,6 +12228,7 @@ export function issueService(db: Db) {
}
const staleExecutionRunId = current.executionRunId;
const adopted = await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
return tx
@@ -12524,6 +12536,7 @@ export function issueService(db: Db) {
patch.assigneeAgentId = null;
}
await notifyStatusCardIssueChange(tx, existing.companyId);
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
if (existing.checkoutRunId || existing.executionRunId || options.clearAssignee) {
await notifyExternalConversationSettlement(tx, { companyId: existing.companyId, issueId: id });
@@ -12581,12 +12594,12 @@ export function issueService(db: Db) {
return created;
},
deleteLabel: async (id: string) =>
db
.delete(labels)
.where(eq(labels.id, id))
.returning()
.then((rows) => rows[0] ?? null),
deleteLabel: async (id: string) => db.transaction(async tx => {
const [label] = await tx.select({ companyId: labels.companyId }).from(labels).where(eq(labels.id, id));
if (!label) return null;
await notifyStatusCardIssueChange(tx, label.companyId);
return tx.delete(labels).where(eq(labels.id, id)).returning().then(rows => rows[0] ?? null);
}),
listComments: async (
issueId: string,
@@ -12838,7 +12851,7 @@ export function issueService(db: Db) {
},
dbOrTx: any = db,
): Promise<IssueComment> {
if (dbOrTx === db && (actor.runId || actor.userId)) {
if (dbOrTx === db) {
const append = () =>
measureRunTransaction(db, "response", async (tx) => {
// Serialize run-authored comments on the issue so a provider retry
@@ -13081,6 +13094,7 @@ export function issueService(db: Db) {
currentUserRedactionOptions.enabled,
);
}
await notifyStatusCardIssueChange(dbOrTx, issue.companyId);
comment =
shouldUpgradeExternalAuthorization ||
shouldUpgradeAttachmentAuthorization
@@ -1,8 +1,10 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export type NativeStatusReassessmentScope = { companyId: string; issueId?: string; runId?: string; agentId?: string };
export function notifyNativeStatusReassessment(tx: object, scope: NativeStatusReassessmentScope) {
export async function notifyNativeStatusReassessment(tx: object, scope: NativeStatusReassessmentScope) {
await notifyStatusCardIssueChange(tx, scope.companyId);
return notifyDeliveryWork(tx, DELIVERY_QUEUES.nativeStatusReassessment,
JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId, runId: scope.runId, agentId: scope.agentId }));
}
+3
View File
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js";
import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js";
@@ -1013,6 +1014,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
const row = await db.transaction(async (tx) => {
if (updates.visibility !== undefined) {
await notifyStatusCardIssueChange(tx, existingProject.companyId);
await notifyChatInboundWakeNotices(tx, { companyId: existingProject.companyId });
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existingProject.companyId}`}, 0))`);
}
@@ -1086,6 +1088,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
await tx.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, id, "delete");
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
await notifyStatusCardIssueChange(tx, project.companyId);
const [row] = await tx.delete(projects).where(eq(projects.id, id)).returning();
return row ? { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) } : null;
}),
@@ -18,17 +18,18 @@ export function statusCardEvaluationService(db: Db, heartbeat: IssueAssignmentWa
let reconstructReactive = true;
let allChanges = false;
const changes = new Set<string>();
let enabled = false;
let enabled: boolean | null = null;
let retryAt: number | null = null;
return {
changed(hints?: ReadonlySet<string>) {
for (const hint of hints ?? ["*"]) {
if (hint === "*" || hint === "changes:*") allChanges = true;
if (hint === "*" || hint === "changes:*") { allChanges = true; if (hint === "*") enabled = null; }
else if (hint.startsWith("changes:")) changes.add(hint.slice("changes:".length));
}
},
async run(signal: AbortSignal) {
enabled = (await settings.getExperimental()).enableStatusCards === true;
if (enabled === null) enabled = (await settings.getExperimental()).enableStatusCards === true;
if (!enabled || signal.aborted) return;
const reconstruct = reconstructReactive;
const evaluateAllChanges = allChanges;
@@ -37,7 +38,9 @@ export function statusCardEvaluationService(db: Db, heartbeat: IssueAssignmentWa
reconstructReactive = false;
allChanges = false;
try {
const result = await cards.tickDueStatusCards(new Date(), evaluateAllChanges ? "changed" : reconstruct && companiesChanged.size === 0 ? "all" : companiesChanged);
const result = await cards.tickDueStatusCards(new Date(), evaluateAllChanges ? "changed" : companiesChanged, reconstruct);
retryAt = result.deferredCompanyIds.size ? Date.now() + 30_000 : null;
for (const companyId of result.deferredCompanyIds) changes.add(companyId);
const assignments = await Promise.allSettled(result.enqueued.map(async ({ cardId, generatingIssue }) => {
// Once generated, dispatch or cancel even if shutdown begins: leaving
// an unassigned claimed card is not a completed sweep.
@@ -74,7 +77,8 @@ export function statusCardEvaluationService(db: Db, heartbeat: IssueAssignmentWa
.where(and(eq(companies.status, "active"), isNull(statusCards.archivedAt),
isNull(statusCards.generatingIssueId), isNotNull(statusCards.nextEvalAt)))
.orderBy(asc(statusCards.nextEvalAt)).limit(1);
return next?.at?.getTime() ?? null;
const deadline = next?.at?.getTime() ?? null;
return retryAt === null ? deadline : Math.min(deadline ?? Infinity, retryAt);
},
};
}
+23 -30
View File
@@ -25,7 +25,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
import { builtInAgentService } from "./built-in-agents.js";
import { companySearchService, companySearchUpdatedWithinMs } from "./company-search.js";
import { companySearchService } from "./company-search.js";
import { issueService } from "./issues.js";
import { SUMMARIZER_BUILT_IN_KEY } from "./summary-slots.js";
import {
@@ -47,7 +47,6 @@ import {
type StatusCardActor = { agentId: string | null; userId: string | null };
type StatusCardWriter = { agentId: string | null; runId: string | null };
type StatusCardRow = typeof statusCards.$inferSelect;
type WatchedIssue = CompanySearchIssueSummary & { scopeExpiresAt?: Date | null };
type StatusCardReadOptions = { issueReadCondition?: SQL<boolean> };
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
@@ -590,22 +589,15 @@ export function statusCardService(
return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? [], options);
}
async function executeQueries(card: StatusCardRow, options: StatusCardReadOptions = {}) {
const issueMap = new Map<string, WatchedIssue>();
async function executeQueries(card: StatusCardRow, options: StatusCardReadOptions = {}, onNextMembershipChange?: (at: Date | null) => void) {
const issueMap = new Map<string, CompanySearchIssueSummary>();
for (const storedQuery of card.queries) {
const query = companySearchQuerySchema.parse(storedQuery);
const response = await searchSvc.search(card.companyId, query, {
issueReadCondition: options.issueReadCondition,
issueReadCondition: options.issueReadCondition, onNextMembershipChange,
});
for (const result of response.results) {
if (result.type !== "issue" || !result.issue) continue;
const duration = companySearchUpdatedWithinMs(query.updatedWithin);
const expiry = duration === null ? null : new Date(new Date(result.issue.updatedAt).getTime() + duration + 1);
const previous = issueMap.get(result.issue.id);
issueMap.set(result.issue.id, { ...result.issue, scopeExpiresAt: previous
? previous.scopeExpiresAt == null || expiry === null ? null
: new Date(Math.max(previous.scopeExpiresAt.getTime(), expiry.getTime()))
: expiry });
if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue);
}
}
// Issues mentioned in the latest summary join the watched set alongside the
@@ -615,11 +607,7 @@ export function statusCardService(
(card.mentionedIssueIds ?? []).filter((issueId) => !issueMap.has(issueId)),
options,
);
for (const issue of mentioned) issueMap.set(issue.id, { ...issue, scopeExpiresAt: null });
for (const id of card.mentionedIssueIds ?? []) {
const issue = issueMap.get(id);
if (issue) issue.scopeExpiresAt = null;
}
for (const issue of mentioned) issueMap.set(issue.id, issue);
const snapshot = [...issueMap.values()];
if (snapshot.length === 0) return snapshot;
const latestHumanComments = await db
@@ -643,11 +631,10 @@ export function statusCardService(
return snapshot.map((issue) => ({ ...issue, latestHumanCommentAt: commentByIssueId.get(issue.id) ?? null }));
}
function nextEvaluationAt(card: StatusCardRow, snapshot: WatchedIssue[], now: Date) {
function nextEvaluationAt(card: StatusCardRow, membershipAt: Date | null, now: Date) {
const interval = nextStatusCardEvaluationAt(card.refreshPolicy, now);
if (card.refreshPolicy.mode !== "reactive" || !card.refreshPolicy.triggers.membershipChanges) return interval;
const expiries = snapshot.flatMap(issue => issue.scopeExpiresAt && issue.scopeExpiresAt > now ? [issue.scopeExpiresAt.getTime()] : []);
return expiries.length ? new Date(Math.min(...expiries)) : null;
return card.refreshPolicy.mode === "reactive" && (card.refreshPolicy.triggers.membershipChanges || card.refreshPolicy.triggers.anyUpdate)
? membershipAt : interval;
}
async function requestRefresh(cardId: string, input: {
@@ -670,13 +657,14 @@ export function statusCardService(
}
const now = input.now ?? new Date();
const snapshot = await executeQueries(card);
let membershipAt: Date | null = null;
const snapshot = await executeQueries(card, {}, at => { if (at && (!membershipAt || at < membershipAt)) membershipAt = at; });
const fingerprint = buildStatusCardFingerprint(snapshot);
const allChanges = diffStatusCardFingerprint(card.fingerprint as StatusCardFingerprint | null, fingerprint);
const changes = filterStatusCardChanges(allChanges, card.refreshPolicy);
const trigger = input.trigger ?? "manual";
const forceRun = trigger === "manual" || trigger === "restore";
const nextEvalAt = nextEvaluationAt(card, snapshot, now);
const nextEvalAt = nextEvaluationAt(card, membershipAt, now);
if (!forceRun && changes.length === 0) {
const [next] = await mutate(card.companyId, tx => tx.update(statusCards).set({
pendingChangeCount: 0,
@@ -797,9 +785,12 @@ export function statusCardService(
return { card: next, generatingIssue: generationIssue!, alreadyGenerating: deduplicated, enqueued: true, kind, changes };
}
async function tickDueStatusCards(now = new Date(), reactiveCompanies?: ReadonlySet<string> | "all" | "changed") {
const reactiveScope = reactiveCompanies === "all" ? isNull(statusCards.nextEvalAt) : reactiveCompanies === "changed" ? sql`true` : reactiveCompanies?.size
async function tickDueStatusCards(now = new Date(), reactiveCompanies?: ReadonlySet<string> | "changed", reconstruct = false) {
const changedScope = reactiveCompanies === "changed" ? sql`true` : reactiveCompanies?.size
? inArray(statusCards.companyId, [...reactiveCompanies]) : sql`false`;
// Startup restores every disarmed reactive scope. A concurrent committed
// source hint can additionally preempt its company's saved future deadline.
const reactiveScope = or(reconstruct ? isNull(statusCards.nextEvalAt) : undefined, changedScope);
const due = await db.select({ card: statusCards }).from(statusCards)
.innerJoin(companies, eq(companies.id, statusCards.companyId))
.where(and(eq(companies.status, "active"), isNull(statusCards.archivedAt), isNull(statusCards.generatingIssueId),
@@ -808,6 +799,7 @@ export function statusCardService(
.then(rows => rows.map(row => row.card));
const enqueued: Array<{ cardId: string; generatingIssue: typeof issues.$inferSelect }> = [];
let evaluated = 0;
const deferredCompanyIds = new Set<string>();
for (const candidate of due) {
// nextEvalAt is both a retry lease and an evaluation deadline. A source
// event may preempt a genuine future deadline, so serialize scheduler
@@ -817,7 +809,7 @@ export function statusCardService(
const [lock] = await lockTx.execute<{ acquired: boolean }>(
sql`select pg_try_advisory_xact_lock(hashtextextended(${candidate.id}, 0)) as acquired`,
);
if (!lock?.acquired) return;
if (!lock?.acquired) { deferredCompanyIds.add(candidate.companyId); return; }
const claimUntil = new Date(now.getTime() + 5 * 60 * 1000);
const [claimed] = await mutate(candidate.companyId, tx => tx.update(statusCards).set({ nextEvalAt: claimUntil })
.where(and(eq(statusCards.id, candidate.id), isNull(statusCards.archivedAt), isNull(statusCards.generatingIssueId),
@@ -836,7 +828,7 @@ export function statusCardService(
}
});
}
return { evaluated, enqueued };
return { evaluated, enqueued, deferredCompanyIds };
}
async function writeSummary(cardId: string, input: WriteStatusCardSummary, actor: StatusCardWriter) {
@@ -866,7 +858,8 @@ export function statusCardService(
const mentionedIssueIds = await resolveMentionedIssueIds(current.companyId, input.markdown);
// Current watched membership: compiled-query matches plus the issues this
// summary mentions.
const watchedSnapshot = await executeQueries({ ...current, mentionedIssueIds });
let membershipAt: Date | null = null;
const watchedSnapshot = await executeQueries({ ...current, mentionedIssueIds }, {}, at => { if (at && (!membershipAt || at < membershipAt)) membershipAt = at; });
const watchedNow = buildStatusCardFingerprint(watchedSnapshot);
let snapshot: StatusCardFingerprint;
if (payloadFingerprint) {
@@ -940,7 +933,7 @@ export function statusCardService(
lastChangeAt: null,
nextEvalAt: current.refreshPolicy.mode === "reactive" &&
filterStatusCardChanges(diffStatusCardFingerprint(snapshot, watchedNow), current.refreshPolicy).length > 0
? now : nextEvaluationAt(current, watchedSnapshot, now),
? now : nextEvaluationAt(current, membershipAt, now),
updatedAt: now,
}).where(and(eq(statusCards.id, current.id), eq(statusCards.generatingIssueId, input.generationIssueId))).returning();
if (!next) throw conflict("Status-card generation was superseded by a newer task");
+5 -1
View File
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { createHash } from "node:crypto";
import { and, asc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -1398,10 +1399,13 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {})
}) ?? fallback
: fallback;
if (!shouldReopen && watchdogIssue.originFingerprint !== input.classification.stopFingerprint) {
await db
await db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, input.watchdog.companyId);
await tx
.update(issues)
.set({ originFingerprint: input.classification.stopFingerprint, updatedAt: new Date() })
.where(and(eq(issues.companyId, input.watchdog.companyId), eq(issues.id, watchdogIssue.id)));
});
watchdogIssue.originFingerprint = input.classification.stopFingerprint;
}
await issuesSvc.addComment(
+12 -10
View File
@@ -1,3 +1,4 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { insertIssueComment } from "./issue-comment-writes.js";
import { restoreGitWorkspaceContents } from "./git-workspace-contents.js";
import { savedGitWorkspaceRestoration, ensureSavedGitCommit, GitCacheRestorationFailure } from "./git-workspace-restoration.js";
@@ -1636,10 +1637,11 @@ async function writeDirtyQuarantineAuditComments(input: {
let sourceAuditCommentId: string | null = null;
let claimantAuditCommentId: string | null = null;
const now = new Date();
if (input.evidence.sourceIssueId) {
const sourceIssueId = input.evidence.sourceIssueId;
if (sourceIssueId) {
const [sourceComment] = await insertIssueComment(input.db, {
companyId: input.companyId,
issueId: input.evidence.sourceIssueId,
issueId: sourceIssueId,
authorAgentId: null,
authorUserId: null,
authorType: "system",
@@ -1647,10 +1649,10 @@ async function writeDirtyQuarantineAuditComments(input: {
body,
});
sourceAuditCommentId = sourceComment?.id ?? null;
await input.db
.update(issues)
.set({ updatedAt: now })
.where(eq(issues.id, input.evidence.sourceIssueId));
await input.db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, input.companyId);
await tx.update(issues).set({ updatedAt: now }).where(eq(issues.id, sourceIssueId));
});
}
const claimantIssueId = input.evidence.contention?.claimedByIssueId ?? null;
@@ -1665,10 +1667,10 @@ async function writeDirtyQuarantineAuditComments(input: {
body,
});
claimantAuditCommentId = claimantComment?.id ?? null;
await input.db
.update(issues)
.set({ updatedAt: now })
.where(eq(issues.id, claimantIssueId));
await input.db.transaction(async tx => {
await notifyStatusCardIssueChange(tx, input.companyId);
await tx.update(issues).set({ updatedAt: now }).where(eq(issues.id, claimantIssueId));
});
}
return { sourceAuditCommentId, claimantAuditCommentId };