mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Budget admission decides if new agent work can start in a company > - Five admission gates waited on the cleanup or accounting of other runs > - The restart-chaos campaign found that these gates froze companies for up to 10 minutes, caused false budget pauses, denied in-run writes, and dropped wakes, comments and Retries > - The durable ownership plan (Decision 1) removes the company-wide accounting gate. It does not narrow it again > - This pull request removes the five gates. Unfinished accounting now adds its bounded estimate to exposure > - The benefit is that one run's unsettled accounting never stops the other work in its company ## Linked Issues or Issue Description Refs #19831 (plan, section D), #19838 (inventory, finding 3), #19927 (Phase 2 reconciler data), #19497, #19724, #19826. **What happened?** Admission refused new work in these cases, with no reached hard stop and no pause: - a terminal run awaited accounting (`waitingRunCount`); - a native run recovered its accounting (`recoveringRunCount`); - a budget-owned scope hold awaited reconciliation, after the hard stop no longer applied; - a budget input was older than 15 s (`budget_evaluation_work`); - the predecessor run row of a task was missing (`hasPendingRunCleanup` failed closed). **Expected behavior** Admission never waits on the cleanup or accounting of another run. Only a reached hard stop, an explicit pause or an inactive company refuses new work (invariant 3). **Steps to reproduce** Make a company with an active budget policy. Leave one failed run with `cost_accounting_pending = true`. Request work for another agent. On `master` admission refuses with "Company cannot start work while completed runs await accounting." ## What Changed - `budgets.ts`: admission refuses only for a reached hard stop or an explicit pause. The code removes `observedBlocks`, the `recoveringRunCount` check, the stale `budgetHeld` refusal and the 15 s input-age gate. It also removes the unused `inspectInvocationEligibility`. `reconcileScope` no longer returns accounting holds. - `budget-exposure.ts`: removes the `waiting` and `recovering` aggregates and the own-task subquery. The admission query is smaller. Unfinished accounting stays exposure: the larger of the held reservation and the observed cost. - `budget-evaluation-work.ts`: removes `oldestBudgetInputAt`, which nothing reads now. - `execution-blocker.ts`: `hasPendingRunCleanup` returns false for a missing run row. A run row that is deleted removes its finalization coordinator and nulls its lease references, so the row owns nothing. A predecessor that owns a lease or a coordinator still holds the successor of its own task. - `recovery/ownership-invariant-report.ts`: the I3 check no longer mirrors the removed gate. It reports a wake request or a run that was refused with an accounting-hold reason. Any such report is a regression. - `doc/SPEC-implementation.md`: adds the product rule "Admission is independent of cleanup and accounting". `doc/execution-semantics.md` is aligned. ## Verification - New `server/src/__tests__/admission-independent-of-cleanup.test.ts`, on embedded Postgres. It has one case per gate. All six cases fail on `master` and pass here. A seventh case (an explicit pause still refuses) passes on both. One case also asserts invariant 3 together with exposure: two failed runs that each hold a 400-cent reservation fill a 1000-cent budget, and a hard stop still refuses. - Existing tests that asserted the old gates now assert admission (invariants 3 and 4): `closed-native-receipt-accounting`, `budgets-service`, `budget-evaluation-work`, `heartbeat-held-comment-wake`, `ownership-reconciliation-report`. In `heartbeat-held-comment-wake`, each saved comment, Retry, assignment and continuation is delivered exactly once while another run's accounting is pending. Settling accounting later does not deliver it again. - All six files pass locally, one file per process. The server package typecheck passes. - Speed: admission is on the turn path. This change only removes work from it: two correlated aggregates, one own-task subquery and one `budget_evaluation_work` read per policy. No wait or I/O is added before `turn.start`. - Not run here: the chaos waves (K13 storm, K6 crash loop, K4 long holds). The coordinating session schedules them on the shared harness. ## Risks - Behavior change: work starts while accounting for other runs settles. Spend stays bounded by the reservation capacity check and the hard stop, which read the ledger plus observed cost. A run that has no reservation and no observed cost adds zero exposure until its charge commits. This is the gap that Decision 1 accepts. Budget enforcement was already best effort (SPEC 11.6). - A budget-owned pause with no reached hard stop no longer refuses work in budget admission. The paused company or agent is still refused by the existing pause checks until the budget worker lifts the pause. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
271 lines
18 KiB
TypeScript
271 lines
18 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { agents, budgetPolicies, budgetReservations, companies, costEvents, createDb, heartbeatRuns, projects, type Db } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { budgetService, computeObservedSpend, flushBudgetEvaluationWork } from "../services/budgets.js";
|
|
import { reserveRunBudget } from "../services/budget-reservations.js";
|
|
import { accountRunCost as accountRun } from "../services/run-cost-accounting.js";
|
|
import { persistUsageReceipt as persistReceipt, type UsageReceiptEnvelope } from "../services/usage-receipts.js";
|
|
import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("scoped live budget enforcement", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("budget-live-scoping-");
|
|
db = createDb(database.connectionString);
|
|
}, 60_000);
|
|
afterEach(async () => { await db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL client_min_messages = warning`);
|
|
await tx.execute(sql`TRUNCATE companies CASCADE`);
|
|
}); });
|
|
afterAll(async () => { await database?.cleanup(); });
|
|
|
|
// The explicit worker turn separates receipt commit from budget transitions.
|
|
async function persistUsageReceipt(...args: Parameters<typeof persistReceipt>) {
|
|
const result = await persistReceipt(...args);
|
|
await flushBudgetEvaluationWork(db);
|
|
return result;
|
|
}
|
|
async function accountRunCost(...args: Parameters<typeof accountRun>) {
|
|
const result = await accountRun(...args);
|
|
await flushBudgetEvaluationWork(db);
|
|
return result;
|
|
}
|
|
async function fixture(options: { scope?: "company" | "agent" | "project"; amount?: number; reservationCents?: string } = {}) {
|
|
const companyId = randomUUID(), agentId = randomUUID(), otherAgentId = randomUUID();
|
|
const projectId = randomUUID(), runId = randomUUID(), sourceId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Budget live", issuePrefix: "BLIVE" });
|
|
await db.insert(agents).values([
|
|
{ id: agentId, companyId, name: "Running", role: "engineer", status: "active", adapterType: "process" },
|
|
{ id: otherAgentId, companyId, name: "Unrelated idle", role: "engineer", status: "idle", adapterType: "process" },
|
|
]);
|
|
await db.insert(projects).values({ id: projectId, companyId, name: "Scoped project", status: "in_progress" });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", status: "running",
|
|
costAccountingPending: true, usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0,
|
|
accountingReceiptReady: false, ledgerScope: { projectId } } });
|
|
const scopeType = options.scope ?? "agent";
|
|
const [policy] = await db.insert(budgetPolicies).values({ companyId, scopeType,
|
|
scopeId: scopeType === "company" ? companyId : scopeType === "project" ? projectId : agentId,
|
|
metric: "billed_cents", windowKind: "lifetime", amount: options.amount ?? 100,
|
|
reservationCents: options.reservationCents ?? "0", notifyEnabled: false }).returning();
|
|
const readRun = async () => (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0];
|
|
const exposure = () => computeObservedSpend(db, policy);
|
|
const receipt = (sequence: number, costUsd?: number, extra: Partial<UsageReceiptEnvelope["receipt"]> = {}): UsageReceiptEnvelope => ({
|
|
schema: "paperclip/accounting-receipt/v1", id: randomUUID(), companyId, runId, sourceId, sequence,
|
|
receivedAt: new Date().toISOString(), adapterType: "process", receipt: {
|
|
complete: false, usageBasis: "per_run", provider: "test", biller: "test", model: "budget-fixture",
|
|
billingType: "metered_api", costStatus: "unpriced", ...(costUsd === undefined ? {} : { costUsd }), ...extra,
|
|
},
|
|
});
|
|
const queuedRun = async (id = randomUUID()) => {
|
|
await db.insert(heartbeatRuns).values({ id, companyId, agentId, invocationSource: "on_demand", status: "queued" });
|
|
return id;
|
|
};
|
|
return { companyId, agentId, otherAgentId, projectId, runId, sourceId, policy, readRun, exposure, receipt, queuedRun };
|
|
}
|
|
|
|
it("blocks a known streaming overrun without charging checkpoints or touching unrelated agents", async () => {
|
|
const f = await fixture();
|
|
const [before] = await db.select().from(agents).where(eq(agents.id, f.otherAgentId));
|
|
await persistUsageReceipt(db, f.receipt(1, 0.6));
|
|
expect((await f.exposure()).totalExact).toBe("60.0000000");
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: f.projectId })).toBeNull();
|
|
await persistUsageReceipt(db, f.receipt(2, 1.1));
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, f.agentId));
|
|
const [policy] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, f.policy.id));
|
|
const [other] = await db.select().from(agents).where(eq(agents.id, f.otherAgentId));
|
|
expect(agent.lifecycleHolds).toContain("budget");
|
|
expect(policy.enforcementVersion).toBeGreaterThan(policy.enforcementDeliveredVersion);
|
|
expect(other).toEqual(before);
|
|
expect(await db.select().from(costEvents)).toHaveLength(0);
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: f.projectId })).not.toBeNull();
|
|
expect((await f.exposure()).liveObservedCents).toBe("110.0000000");
|
|
});
|
|
|
|
it("replaces cumulative observations and preserves known exposure across missing, stale, and lower partial costs", async () => {
|
|
const f = await fixture({ amount: 1000 });
|
|
const first = f.receipt(1, 0.4);
|
|
await persistUsageReceipt(db, first);
|
|
await persistUsageReceipt(db, f.receipt(2, 0.7));
|
|
await persistUsageReceipt(db, first);
|
|
await persistUsageReceipt(db, f.receipt(3));
|
|
await persistUsageReceipt(db, { ...f.receipt(1, 20), sourceId: randomUUID() });
|
|
await persistUsageReceipt(db, f.receipt(4, 0.5));
|
|
expect((await f.exposure()).liveObservedCents).toBe("70.0000000");
|
|
expect((await f.readRun()).usageJson?.budgetExposure).toMatchObject({ sourceId: f.sourceId, sequence: 4, costCents: "70.0000000" });
|
|
expect(await db.select().from(costEvents)).toHaveLength(0);
|
|
});
|
|
|
|
it("counts the maximum of a reservation and live spend once at final dispatch", async () => {
|
|
const f = await fixture({ reservationCents: "40" });
|
|
await db.insert(budgetReservations).values({ companyId: f.companyId, runId: f.runId, agentId: f.agentId,
|
|
projectId: f.projectId, amountCents: "50", providerStartedAt: new Date() });
|
|
await persistUsageReceipt(db, f.receipt(1, 0.6));
|
|
expect((await f.exposure()).effectiveHeldCents).toBe("60.0000000");
|
|
const nextRunId = await f.queuedRun();
|
|
expect((await reserveRunBudget(db, f.companyId, nextRunId, f.projectId)).amountCents).toBe("40.0000000");
|
|
expect((await f.exposure()).effectiveHeldCents).toBe("100.0000000");
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: f.projectId })).toBeNull();
|
|
await expect(reserveRunBudget(db, f.companyId, await f.queuedRun(), f.projectId)).rejects.toThrow("reserved");
|
|
});
|
|
|
|
it("retains each best-effort competing dispatch reservation", async () => {
|
|
const f = await fixture({ reservationCents: "60" });
|
|
const first = await f.queuedRun(), second = await f.queuedRun();
|
|
const outcomes = await Promise.allSettled([
|
|
reserveRunBudget(db, f.companyId, first, f.projectId), reserveRunBudget(db, f.companyId, second, f.projectId),
|
|
]);
|
|
const admitted = outcomes.filter(result => result.status === "fulfilled").length;
|
|
expect(admitted).toBeGreaterThanOrEqual(1);
|
|
expect((await f.exposure()).effectiveHeldCents).toBe(`${admitted * 60}.0000000`);
|
|
});
|
|
|
|
it("commits a budget release while dispatch waits for asynchronous lifecycle readiness", async () => {
|
|
const f = await fixture();
|
|
const worker = configureAgentLifecycle(db, {
|
|
requiredPluginIds: async () => [], runHost: async () => "complete", runPlugin: async () => "complete",
|
|
});
|
|
try {
|
|
await persistUsageReceipt(db, f.receipt(1, 1.1));
|
|
await worker.process(f.agentId);
|
|
expect((await db.select().from(agents).where(eq(agents.id, f.agentId)))[0].lifecycleState).toBe("paused");
|
|
await db.update(budgetPolicies).set({ amount: 200 }).where(eq(budgetPolicies.id, f.policy.id));
|
|
await budgetService(db).evaluateAffectedScopes(f.companyId, f.agentId, f.projectId);
|
|
await flushBudgetEvaluationWork(db);
|
|
const nextRunId = await f.queuedRun();
|
|
await expect(reserveRunBudget(db, f.companyId, nextRunId, f.projectId)).rejects.toThrow("lifecycle");
|
|
const [resuming] = await db.select().from(agents).where(eq(agents.id, f.agentId));
|
|
expect(resuming.lifecycleHolds).not.toContain("budget");
|
|
expect(resuming.lifecycleState).toBe("resuming");
|
|
expect(await db.select().from(budgetReservations).where(eq(budgetReservations.runId, nextRunId))).toHaveLength(0);
|
|
await worker.process(f.agentId);
|
|
expect((await db.select().from(agents).where(eq(agents.id, f.agentId)))[0].lifecycleState).toBe("ready");
|
|
await expect(reserveRunBudget(db, f.companyId, nextRunId, f.projectId)).resolves.toMatchObject({ reused: false });
|
|
} finally { await worker.stop(); }
|
|
});
|
|
|
|
it("atomically replaces live exposure and its hold with one final ledger charge", async () => {
|
|
const f = await fixture({ amount: 1000 });
|
|
await db.insert(budgetReservations).values({ companyId: f.companyId, runId: f.runId, agentId: f.agentId,
|
|
projectId: f.projectId, amountCents: "50", providerStartedAt: new Date() });
|
|
await persistUsageReceipt(db, f.receipt(1, 0.6));
|
|
await persistUsageReceipt(db, f.receipt(2, 0.8, { complete: true, costStatus: "reported" }));
|
|
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await accountRunCost(db, f.runId)).toBe(true);
|
|
expect(await accountRunCost(db, f.runId)).toBe(false);
|
|
expect(await f.exposure()).toMatchObject({ ledgerCents: "80.0000000", liveObservedCents: "0.0000000",
|
|
effectiveHeldCents: "0.0000000", totalExact: "80.0000000", pendingTerminalRunCount: 0 });
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.companyId))).toHaveLength(1);
|
|
const [hold] = await db.select().from(budgetReservations).where(eq(budgetReservations.runId, f.runId));
|
|
expect(hold.state).toBe("settled");
|
|
});
|
|
|
|
it("keeps an over-budget hold stable while replacing live usage with a multipart final receipt", async () => {
|
|
const f = await fixture();
|
|
await persistUsageReceipt(db, f.receipt(1, 1.1));
|
|
await persistUsageReceipt(db, f.receipt(2, 1.2, { complete: true, costStatus: "reported", usage: { inputTokens: 2, outputTokens: 2 },
|
|
usageByModel: [
|
|
{ model: "first", costUsd: 0.3, usage: { inputTokens: 1, outputTokens: 1 } },
|
|
{ model: "second", costUsd: 0.9, usage: { inputTokens: 1, outputTokens: 1 } },
|
|
] }));
|
|
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
|
|
const [before] = await db.select().from(agents).where(eq(agents.id, f.agentId));
|
|
expect(before.lifecycleHolds).toContain("budget");
|
|
await accountRunCost(db, f.runId);
|
|
const [after] = await db.select().from(agents).where(eq(agents.id, f.agentId));
|
|
expect(after.lifecycleHolds).toContain("budget");
|
|
expect(after.lifecycleVersion).toBe(before.lifecycleVersion);
|
|
expect(after.lifecycleOperation).toEqual(before.lifecycleOperation);
|
|
expect(await f.exposure()).toMatchObject({ ledgerCents: "120.0000000", liveObservedCents: "0.0000000" });
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.companyId))).toHaveLength(2);
|
|
});
|
|
|
|
it("recognizes legacy active-run usage without a projection and excludes subscription charges", async () => {
|
|
const f = await fixture();
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
await db.update(heartbeatRuns).set({ usageJson: { ...run.usageJson, costUsd: 0.75, usageSource: "per_run", billingType: "metered_api" } })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
expect((await f.exposure()).liveObservedCents).toBe("75.0000000");
|
|
await persistUsageReceipt(db, f.receipt(1, 10, { billingType: "subscription_included" }));
|
|
expect((await f.exposure()).liveObservedCents).toBe("0.0000000");
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: f.projectId })).toBeNull();
|
|
});
|
|
|
|
it("retains established per-run exposure when a later receipt has an unreconciled session basis", async () => {
|
|
const f = await fixture();
|
|
await persistUsageReceipt(db, f.receipt(1, 0.7));
|
|
await persistUsageReceipt(db, f.receipt(2, 99, { usageBasis: "session_cumulative" }));
|
|
expect((await f.exposure()).liveObservedCents).toBe("70.0000000");
|
|
expect((await f.readRun()).usageJson?.budgetExposure).toMatchObject({ costCents: "70.0000000" });
|
|
});
|
|
|
|
it("ignores out-of-range legacy numeric text without losing a usable fallback price", async () => {
|
|
const f = await fixture();
|
|
const run = await f.readRun();
|
|
await db.update(heartbeatRuns).set({ usageJson: { ...run.usageJson, usageSource: "per_run", billingType: "metered_api",
|
|
cacheAdjustedCostUsd: "1e999", costUsdExact: "1e999", costUsd: 0.25 } }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect((await f.exposure()).liveObservedCents).toBe("25.0000000");
|
|
});
|
|
|
|
it("rounds each legacy run to nanodollars before aggregating its exposure", async () => {
|
|
const f = await fixture();
|
|
const secondRunId = randomUUID();
|
|
const usage = { usageSource: "per_run", billingType: "metered_api", costUsdExact: "0.0000000004" };
|
|
await db.update(heartbeatRuns).set({ usageJson: usage }).where(eq(heartbeatRuns.id, f.runId));
|
|
await db.insert(heartbeatRuns).values({ id: secondRunId, companyId: f.companyId, agentId: f.agentId,
|
|
invocationSource: "on_demand", status: "running", costAccountingPending: true, usageJson: usage });
|
|
expect((await f.exposure()).liveObservedCents).toBe("0.0000000");
|
|
await db.update(heartbeatRuns).set({ usageJson: { ...usage, costUsdExact: "0.0000000005" } })
|
|
.where(eq(heartbeatRuns.companyId, f.companyId));
|
|
expect((await f.exposure()).liveObservedCents).toBe("0.0000002");
|
|
});
|
|
|
|
it("limits project enforcement to that project while the same agent can serve another", async () => {
|
|
const f = await fixture({ scope: "project" });
|
|
const [otherProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Other project", status: "in_progress" }).returning();
|
|
await persistUsageReceipt(db, f.receipt(1, 1.1));
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, f.agentId));
|
|
const [project] = await db.select().from(projects).where(eq(projects.id, f.projectId));
|
|
expect(project.pauseReason).toBe("budget");
|
|
expect(agent.lifecycleHolds).not.toContain("budget");
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: f.projectId })).not.toBeNull();
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: otherProject.id })).toBeNull();
|
|
await expect(reserveRunBudget(db, f.companyId, await f.queuedRun(), otherProject.id)).resolves.toMatchObject({ reused: false });
|
|
});
|
|
|
|
// Invariant 3: the missing price stays pending accounting debt, but it is
|
|
// exposure, never an admission gate.
|
|
it("keeps missing-price terminal accounting pending without holding new work", async () => {
|
|
const f = await fixture();
|
|
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: f.projectId })).toBeNull();
|
|
expect(await f.exposure()).toMatchObject({ pendingTerminalRunCount: 1, ledgerCents: "0.0000000", liveObservedCents: "0.0000000" });
|
|
expect(await accountRunCost(db, f.runId)).toBe(false);
|
|
expect((await f.readRun()).costAccountingPending).toBe(true);
|
|
expect(await db.select().from(costEvents).where(and(eq(costEvents.companyId, f.companyId), eq(costEvents.heartbeatRunId, f.runId)))).toHaveLength(0);
|
|
});
|
|
|
|
// Pending accounting never places a budget hold or an admission gate.
|
|
it.each(["pre-provider release", "stale pending marker"])("settles pending accounting after %s without a hold", async kind => {
|
|
const f = await fixture();
|
|
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: f.projectId })).toBeNull();
|
|
await budgetService(db).evaluateAffectedScopes(f.companyId, f.agentId, f.projectId);
|
|
await flushBudgetEvaluationWork(db);
|
|
const [held] = await db.select().from(agents).where(eq(agents.id, f.agentId));
|
|
expect(held.lifecycleHolds).not.toContain("budget");
|
|
await db.update(heartbeatRuns).set(kind === "pre-provider release"
|
|
? { resultJson: { executionRecovery: { providerWorkStarted: false } } }
|
|
: { costAccountedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await accountRunCost(db, f.runId)).toBe(kind === "pre-provider release");
|
|
const [released] = await db.select().from(agents).where(eq(agents.id, f.agentId));
|
|
expect(released.lifecycleHolds).not.toContain("budget");
|
|
expect(await budgetService(db).getInvocationBlock(f.companyId, f.agentId, { projectId: f.projectId })).toBeNull();
|
|
expect((await f.readRun()).costAccountingPending).toBe(false);
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.companyId))).toHaveLength(0);
|
|
});
|
|
});
|