mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip helps people manage AI agents at work. > - The budget service checks whether an agent can start work. > - Preliminary checks acquired the company accounting lock even with no budget policy. > - Independent checks then waited behind accounting writes for the same company. > - This pull request reads one consistent snapshot before it decides whether reconciliation is needed. > - Healthy checks avoid the writer lock. State changes and final paid admission retain their existing authority. > - The benefit is less company-wide contention during routine eligibility checks. ## Linked Issues or Issue Description Refs #17697 (the preliminary-check portion). Refs #17817 (related serialization in agent configuration). Related approach: #17966. That PR adds a precheck for policies that cannot block. This PR also avoids the lock for healthy active hard-stop policies and manual pause reads. It uses a repeatable-read snapshot and leaves public transactional admission locked. It does not add the inactive-policy spend filtering proposed in #17966. **Current behavior** Every public `getInvocationBlock` call takes the company writer lock. Healthy checks can wait behind an unrelated receipt or settlement operation. **Proposed behavior** Read current scope state, policies, exposure and relevant incidents in a read-only snapshot. Return directly when no transition is needed. Otherwise discard the snapshot and reconcile current state under the company lock. **Reason and benefit** Independent preliminary checks can finish while another transaction owns the company row. Final reservations and paid-work admission still serialize against current accounting state. ## What Changed - Separate preliminary eligibility inspection from mutating reconciliation in the budget service. - Share scope loading, pause decisions and soft-incident resolution conditions between the two paths. - Keep no-policy checks free of exposure scans and incident reads. - Add inspection and reconciliation spans to the existing operator-gated Observability path. - Add PostgreSQL contention tests and regressions for pauses, holds, incidents, recovering native accounting and company boundaries. - Use the shared fixture startup timeout. Per-test deadlines and the 500 ms lock timeout stay intact. - Update the implementation contract and tracing documentation. Add dated investigation and future-design notes. The future accounting architecture remains a proposal. ## Verification - 73 targeted tests passed across eight files on implementation commit `244db565`. No tests were skipped. The files cover budget service, enforcement scoping and delivery, live receipt attempts, budget history, run settlement, heartbeat accounting and budget tracing. - Real PostgreSQL tests hold the company accounting lock while routine checks finish. The same reader's authoritative admission instead fails with PostgreSQL lock timeout `55P03`. - Full workspace typecheck passed before the rebase: `node scripts/typecheck-low-memory.mjs`. - Full workspace build passed before the rebase: `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm -r --workspace-concurrency=1 build`. The Runner release binary was rebuilt from source. - `node scripts/check-module-boundaries.mjs` and `git diff --check` passed. - Initial local setup failed before assertions: cold fixture setup exceeded 20 seconds, then a missing SDK build was exposed. The fixture now uses the shared 90-second startup allowance. The SDK was built before the successful test run. An incomplete local Copilot artifact also blocked the first build; the pinned installer repaired it before the successful canonical retry. - The branch rebased cleanly onto master `83b5f7d486`. All 73 targeted tests then passed again on clean head `244db565ae206502b100c3cdf5cd86a0ae91b372` (eight files, no skips). The full local Vitest suite was not run; remote CI must verify the wider suite. - Initial remote CI failed three monitor-scheduler cases at the suite's three-second heartbeat-idle wait. The unchanged suite then passed all 21 tests locally (`pnpm exec vitest run src/__tests__/issue-monitor-scheduler.test.ts`, 70.13 seconds). The one failed-job rerun was cancelled after the review follow-up was pushed; it produced no pass/fail result. No scheduler assertions or timeouts were changed. On updated head `ba2ab2a0`, the unchanged monitor suite passed all 21 tests in 38.381 seconds in [server shard 10/12](https://github.com/paperclipai/paperclip/actions/runs/41702687492/job/126151059714). That shard passed 115 files and 2,677 tests, with no failures (one file and 20 tests skipped by existing suite configuration). - The independent review identified missing recovering-run coverage. The added contention case requires the locked fallback while another accounting writer holds the company row, then verifies the recovery block without pausing or cancelling recovery. The regression is in follow-up commit `ba2ab2a0b001f7bd69e0f86fb89a842e79b0e2b8`. All 19 budget-service tests passed locally (no skips, 48.89 seconds), and `pnpm --filter @paperclipai/server typecheck` passed after the follow-up. - The updated CI run then hit a separate plugin-refresh fixture timeout at its final re-enable settle. The unchanged `external-object-refresh-work.test.ts` suite passed all 24 tests locally (52.02 seconds). Inspection identified a possible fake-clock race: its quiet helper can observe no scheduled deadline while reconstruction is still active, then encounter the existing 30-second reconstruction cooldown. The one same-head failed-job retry [passed](https://github.com/paperclipai/paperclip/actions/runs/41702687492/job/126158227876): refresh suite 24/24, shard 115 files and 4,125 tests passed (one file and four tests skipped by existing configuration). No assertions, timeouts, or product code were changed. - [Full CI](https://github.com/paperclipai/paperclip/actions/runs/41702687492) passed on exact head `ba2ab2a0b001f7bd69e0f86fb89a842e79b0e2b8`: build, typecheck, Runner checks, all server/workspace suites and E2E. Both required aggregates are successful. All 59 checks are terminal: 54 successful, two advisory neutral and three expected skips. The PR is mergeable with a clean merge state. - Deployment latency and CPU effects have not been measured. - Greptile reviewed updated head `ba2ab2a0b001f7bd69e0f86fb89a842e79b0e2b8`: 5/5, no actionable findings or unresolved Greptile threads. - Independent Paperclip review on the updated head: advisory 4/5, no blocker, recovery gap confirmed resolved. One canonical maintainability advisory remains visible and is documented below. ## Risks - Preliminary inspection can precede a concurrent policy or pause change. It grants no paid dispatch authority. The final locked admission and reservation checks remain required. - Healthy configured policies add an incident lookup. An unheld blocking scope can compute exposure during both inspection and locked revalidation. Already-held scopes skip the first exposure computation. - Holds and incidents must retain their existing transition effects. The fallback reads them again under the company lock, rather than mutating from the earlier snapshot. - Eligibility inspection and reconciliation still maintain separate transition lists. Future reconciliation changes must update the inspection predicate and its contention coverage together; consolidating the effects model remains a nonblocking review follow-up. - No schema or API response change is required. This change does not relax the current budget enforcement contract. ## Model Used OpenAI Codex with model `gpt-6.1-sol`. Used reasoning, repository inspection, code execution and delegated review. The session did not expose the context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
573 lines
27 KiB
TypeScript
573 lines
27 KiB
TypeScript
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
agents,
|
|
approvals,
|
|
budgetIncidents,
|
|
budgetPolicies,
|
|
companies,
|
|
costEvents,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import { budgetService, deliverBudgetEnforcement, hasPendingBudgetEnforcement } from "../services/budgets.ts";
|
|
import {
|
|
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../services/activity-log.js", () => ({
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
|
|
// Budget behavior is exercised against PostgreSQL below and in
|
|
// cost-accounting-reliability.test.ts, including transaction and retry paths.
|
|
|
|
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_BUDGETS_TEST_DATABASE_URL");
|
|
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("budgetService release gate enforcement", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
await import("../services/agent-lifecycle.js");
|
|
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-budgets-service-");
|
|
db = createDb(externalUrl ?? tempDb!.connectionString);
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(budgetIncidents);
|
|
await db.delete(approvals);
|
|
await db.delete(budgetPolicies);
|
|
await db.delete(costEvents);
|
|
await db.delete(nativeRunFinalizations);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(issues);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
mockLogActivity.mockClear();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function createBudgetFixture() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `B${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Budget Agent SECRET_TOKEN_SHOULD_NOT_LEAK",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Budget Project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
return { companyId, agentId, projectId };
|
|
}
|
|
|
|
async function insertCostEvent(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
projectId?: string | null;
|
|
costCents: number;
|
|
occurredAt?: Date;
|
|
}) {
|
|
const [event] = await db
|
|
.insert(costEvents)
|
|
.values({
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
projectId: input.projectId ?? null,
|
|
provider: "openai",
|
|
biller: "openai",
|
|
billingType: "metered_api",
|
|
model: "gpt-5-release-gate",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 20,
|
|
costCents: input.costCents,
|
|
occurredAt: input.occurredAt ?? new Date(),
|
|
})
|
|
.returning();
|
|
|
|
return event!;
|
|
}
|
|
|
|
it.each(["no policy", "healthy policy", "warning policy", "manual company pause", "manual project pause", "orphan budget pause", "recovering run"])(
|
|
"checks %s while another accounting writer holds the company lock",
|
|
async state => {
|
|
const { companyId, agentId, projectId } = await createBudgetFixture();
|
|
if (state === "healthy policy" || state === "warning policy" || state === "recovering run") {
|
|
const [policy] = await db.insert(budgetPolicies).values({ companyId, scopeType: "company", scopeId: companyId,
|
|
windowKind: "calendar_month_utc", amount: 100 }).returning();
|
|
if (state !== "recovering run") await insertCostEvent({ companyId, agentId, costCents: state === "warning policy" ? 80 : 20 });
|
|
if (state === "warning policy") {
|
|
const now = new Date();
|
|
await db.insert(budgetIncidents).values({ companyId, policyId: policy.id, scopeType: "company", scopeId: companyId,
|
|
metric: "billed_cents", windowKind: "calendar_month_utc",
|
|
windowStart: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
|
|
windowEnd: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)),
|
|
thresholdType: "soft", amountLimit: 100, amountObserved: 80 });
|
|
}
|
|
}
|
|
if (state === "recovering run") {
|
|
const [issue] = await db.insert(issues).values({ companyId, projectId, title: "Recover accounting" }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, scopeKind: "issue", issueId: issue.id,
|
|
nativeIssueId: issue.id, runtimeMode: "native", status: "failed", finishedAt: new Date(), costAccountingPending: true }).returning();
|
|
await db.insert(nativeRunFinalizations).values({ companyId, issueId: issue.id, runId: run.id, phase: "observed" });
|
|
}
|
|
if (state === "manual company pause" || state === "orphan budget pause") {
|
|
await db.update(companies).set({ status: "paused", pauseReason: state === "orphan budget pause" ? "budget" : "manual" })
|
|
.where(eq(companies.id, companyId));
|
|
}
|
|
if (state === "manual project pause") {
|
|
await db.update(projects).set({ pausedAt: new Date(), pauseReason: "manual" }).where(eq(projects.id, projectId));
|
|
}
|
|
|
|
const reader = createDb(externalUrl ?? tempDb!.connectionString, { maxConnections: 1 });
|
|
let acquired!: () => void, release!: () => void;
|
|
const lockAcquired = new Promise<void>(resolve => { acquired = resolve; });
|
|
const holdLock = new Promise<void>(resolve => { release = resolve; });
|
|
const writer = db.transaction(async tx => {
|
|
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("no key update");
|
|
acquired();
|
|
await holdLock;
|
|
});
|
|
try {
|
|
await Promise.race([lockAcquired, writer]);
|
|
await reader.execute(sql`set lock_timeout = '500ms'`);
|
|
if (state === "recovering run") {
|
|
await expect(budgetService(reader).getInvocationBlock(companyId, agentId, { projectId }))
|
|
.rejects.toMatchObject({ cause: { code: "55P03" } });
|
|
} else {
|
|
const block = await budgetService(reader).getInvocationBlock(companyId, agentId, { projectId });
|
|
if (state === "manual company pause") {
|
|
expect(block).toMatchObject({ scopeType: "company", scopeId: companyId,
|
|
reason: "Company is paused and cannot start new work." });
|
|
} else if (state === "manual project pause") {
|
|
expect(block).toMatchObject({ scopeType: "project", scopeId: projectId,
|
|
reason: "Project is paused and cannot start new work." });
|
|
} else if (state === "orphan budget pause") {
|
|
expect(block).toMatchObject({ scopeType: "company", scopeId: companyId,
|
|
reason: "Budget pause requires a policy or an explicit operator resume." });
|
|
} else expect(block).toBeNull();
|
|
if (state === "warning policy") {
|
|
expect(await reader.select().from(budgetIncidents).where(eq(budgetIncidents.companyId, companyId)))
|
|
.toMatchObject([{ thresholdType: "soft", status: "open", resolvedAt: null }]);
|
|
}
|
|
if (state === "no policy") {
|
|
await expect(budgetService(reader).evaluateAdmission(companyId, agentId, { projectId }))
|
|
.rejects.toMatchObject({ cause: { code: "55P03" } });
|
|
}
|
|
}
|
|
} finally {
|
|
release();
|
|
try { await writer; }
|
|
finally { await reader.$client.end({ timeout: 1 }); }
|
|
}
|
|
if (state === "recovering run") {
|
|
const cancelWorkForScope = vi.fn();
|
|
expect(await budgetService(db, { cancelWorkForScope }).getInvocationBlock(companyId, agentId, { projectId }))
|
|
.toMatchObject({ scopeType: "company", scopeId: companyId,
|
|
reason: "New work must wait while a native run recovers its unfinished accounting." });
|
|
expect(cancelWorkForScope).not.toHaveBeenCalled();
|
|
expect((await db.select().from(companies).where(eq(companies.id, companyId)))[0])
|
|
.toMatchObject({ status: "active", pauseReason: null });
|
|
expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0])
|
|
.toMatchObject({ status: "active", pauseReason: null });
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId)))
|
|
.toMatchObject([{ status: "failed", costAccountingPending: true }]);
|
|
expect(await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.companyId, companyId)))
|
|
.toMatchObject([{ phase: "observed", resultId: null }]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([true, false])("releases an agent budget hold when its policy is healthy (active: %s)", async isActive => {
|
|
const { companyId, agentId } = await createBudgetFixture();
|
|
await db.insert(budgetPolicies).values({ companyId, scopeType: "agent", scopeId: agentId,
|
|
windowKind: "calendar_month_utc", amount: 100, isActive });
|
|
await db.update(agents).set({ status: "paused", lifecycleState: "paused", pauseReason: "budget", pausedAt: new Date(), lifecycleHolds: ["budget"] })
|
|
.where(eq(agents.id, agentId));
|
|
|
|
expect(await budgetService(db).getInvocationBlock(companyId, agentId)).toBeNull();
|
|
expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]).toMatchObject({
|
|
lifecycleState: "resuming", pauseReason: null, lifecycleHolds: [],
|
|
});
|
|
});
|
|
|
|
it.each(["hard", "soft"])("resolves an obsolete %s incident even when the scope no longer has a budget hold", async thresholdType => {
|
|
const { companyId, agentId } = await createBudgetFixture();
|
|
const [policy] = await db.insert(budgetPolicies).values({ companyId, scopeType: "agent", scopeId: agentId,
|
|
windowKind: "calendar_month_utc", amount: 100 }).returning();
|
|
const [incident] = await db.insert(budgetIncidents).values({ companyId, policyId: policy.id, scopeType: "agent", scopeId: agentId,
|
|
metric: "billed_cents", windowKind: "calendar_month_utc", windowStart: new Date(0), windowEnd: new Date(1),
|
|
thresholdType, amountLimit: 100, amountObserved: 100 }).returning();
|
|
|
|
expect(await budgetService(db).getInvocationBlock(companyId, agentId)).toBeNull();
|
|
expect((await db.select().from(budgetIncidents).where(eq(budgetIncidents.id, incident.id)))[0])
|
|
.toMatchObject({ status: "resolved", resolvedAt: expect.any(Date) });
|
|
});
|
|
|
|
it("rejects another company's agent or project before allowing a no-policy invocation", async () => {
|
|
const own = await createBudgetFixture();
|
|
const other = await createBudgetFixture();
|
|
const service = budgetService(db);
|
|
await expect(service.getInvocationBlock(own.companyId, other.agentId)).rejects.toThrow("Budget scope not found");
|
|
await expect(service.getInvocationBlock(own.companyId, own.agentId, { projectId: other.projectId }))
|
|
.rejects.toThrow("Budget scope not found");
|
|
});
|
|
|
|
it("raises one soft incident per window before hard-stopping and preserving safe local audit details", async () => {
|
|
const { companyId, agentId } = await createBudgetFixture();
|
|
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
|
const service = budgetService(db, { cancelWorkForScope });
|
|
const [policy] = await db
|
|
.insert(budgetPolicies)
|
|
.values({
|
|
companyId,
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
metric: "billed_cents",
|
|
windowKind: "calendar_month_utc",
|
|
amount: 100,
|
|
warnPercent: 80,
|
|
hardStopEnabled: true,
|
|
notifyEnabled: true,
|
|
isActive: true,
|
|
})
|
|
.returning();
|
|
|
|
const softEvent = await insertCostEvent({ companyId, agentId, costCents: 80 });
|
|
await service.evaluateCostEvent(softEvent);
|
|
await service.evaluateCostEvent(softEvent);
|
|
|
|
let incidentRows = await db
|
|
.select()
|
|
.from(budgetIncidents);
|
|
expect(incidentRows.filter((incident) => incident.thresholdType === "soft")).toHaveLength(1);
|
|
expect(incidentRows[0]).toMatchObject({
|
|
companyId,
|
|
policyId: policy!.id,
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
thresholdType: "soft",
|
|
amountLimit: 100,
|
|
amountObserved: 80,
|
|
approvalId: null,
|
|
status: "open",
|
|
});
|
|
|
|
const [agentBeforeHardStop] = await db
|
|
.select({ status: agents.status, pauseReason: agents.pauseReason })
|
|
.from(agents);
|
|
expect(agentBeforeHardStop).toEqual({ status: "active", pauseReason: null });
|
|
|
|
const hardEvent = await insertCostEvent({ companyId, agentId, costCents: 25 });
|
|
await service.evaluateCostEvent(hardEvent);
|
|
await service.evaluateCostEvent(hardEvent);
|
|
|
|
incidentRows = await db
|
|
.select()
|
|
.from(budgetIncidents);
|
|
expect(incidentRows.filter((incident) => incident.thresholdType === "soft")).toHaveLength(1);
|
|
expect(incidentRows.filter((incident) => incident.thresholdType === "hard")).toHaveLength(1);
|
|
expect(incidentRows.find((incident) => incident.thresholdType === "soft")).toMatchObject({
|
|
status: "resolved",
|
|
});
|
|
expect(incidentRows.find((incident) => incident.thresholdType === "hard")).toMatchObject({
|
|
amountLimit: 100,
|
|
amountObserved: 105,
|
|
status: "open",
|
|
});
|
|
|
|
const [approval] = await db.select().from(approvals);
|
|
expect(approval).toMatchObject({
|
|
companyId,
|
|
type: "budget_override_required",
|
|
status: "pending",
|
|
});
|
|
|
|
const [agentAfterHardStop] = await db
|
|
.select({ status: agents.status, pauseReason: agents.pauseReason, pausedAt: agents.pausedAt })
|
|
.from(agents);
|
|
expect(agentAfterHardStop).toMatchObject({ status: "paused", pauseReason: "budget" });
|
|
expect(agentAfterHardStop?.pausedAt).toBeInstanceOf(Date);
|
|
expect(cancelWorkForScope).not.toHaveBeenCalled();
|
|
expect(await hasPendingBudgetEnforcement(db)).toBe(true);
|
|
const [pendingPolicy] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy!.id));
|
|
expect(pendingPolicy.enforcementVersion).toBe(1);
|
|
await deliverBudgetEnforcement(db, { cancelWorkForScope }, companyId);
|
|
expect(cancelWorkForScope).toHaveBeenCalledTimes(1);
|
|
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId, createdBefore: expect.any(Date), enforcement: { policyId: expect.any(String), version: expect.any(Number) } });
|
|
|
|
const block = await service.getInvocationBlock(companyId, agentId);
|
|
expect(block).toEqual({
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
scopeName: "Budget Agent SECRET_TOKEN_SHOULD_NOT_LEAK",
|
|
reason: "Agent is paused because its budget hard-stop was reached.",
|
|
});
|
|
|
|
const [afterAdmission] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy!.id));
|
|
expect(afterAdmission.enforcementVersion).toBe(pendingPolicy.enforcementVersion);
|
|
await deliverBudgetEnforcement(db, { cancelWorkForScope }, companyId);
|
|
expect(cancelWorkForScope).toHaveBeenCalledTimes(1);
|
|
const activityCalls = mockLogActivity.mock.calls.map(([, input]) => input);
|
|
expect(activityCalls.filter((call) => call.action === "budget.soft_threshold_crossed")).toHaveLength(1);
|
|
expect(activityCalls.filter((call) => call.action === "budget.hard_threshold_crossed")).toHaveLength(1);
|
|
expect(activityCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
action: "budget.soft_threshold_crossed",
|
|
entityType: "budget_incident",
|
|
details: expect.objectContaining({
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
amountObserved: 80,
|
|
amountLimit: 100,
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
action: "budget.hard_threshold_crossed",
|
|
entityType: "budget_incident",
|
|
details: expect.objectContaining({
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
amountObserved: 105,
|
|
amountLimit: 100,
|
|
approvalId: approval!.id,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
for (const call of activityCalls) {
|
|
expect(JSON.stringify(call.details)).not.toContain("SECRET_TOKEN_SHOULD_NOT_LEAK");
|
|
expect(call.details).not.toHaveProperty("prompt");
|
|
expect(call.details).not.toHaveProperty("message");
|
|
}
|
|
});
|
|
|
|
it("distinguishes a company budget pause from a manual company pause", async () => {
|
|
const { companyId, agentId } = await createBudgetFixture();
|
|
const service = budgetService(db);
|
|
await db.update(companies).set({ status: "paused", pauseReason: "budget" }).where(eq(companies.id, companyId));
|
|
expect(await service.getInvocationBlock(companyId, agentId)).toMatchObject({
|
|
scopeType: "company",
|
|
scopeId: companyId,
|
|
reason: "Budget pause requires a policy or an explicit operator resume.",
|
|
});
|
|
await db.update(companies).set({ pauseReason: null }).where(eq(companies.id, companyId));
|
|
expect(await service.getInvocationBlock(companyId, agentId)).toMatchObject({
|
|
scopeType: "company",
|
|
scopeId: companyId,
|
|
reason: "Company is paused and cannot start new work.",
|
|
});
|
|
});
|
|
|
|
it.each([false, true])("only signals a real project budget pause with concurrent admission checks (already paused: %s)", async alreadyPaused => {
|
|
const { companyId, agentId, projectId } = await createBudgetFixture();
|
|
await db.insert(budgetPolicies).values({ companyId, scopeType: "project", scopeId: projectId,
|
|
metric: "billed_cents", windowKind: "lifetime", amount: 100, hardStopEnabled: true, notifyEnabled: false });
|
|
await insertCostEvent({ companyId, agentId, projectId, costCents: 125 });
|
|
const pausedAt = new Date("2020-01-01T00:00:00.000Z");
|
|
if (alreadyPaused) await db.update(projects).set({ pausedAt, pauseReason: "manual" }).where(eq(projects.id, projectId));
|
|
const dispatch = vi.fn(), deferred = vi.fn();
|
|
const unsubscribe = [subscribeDeliveryWork(db, DELIVERY_QUEUES.queuedRunDispatch, dispatch),
|
|
subscribeDeliveryWork(db, DELIVERY_QUEUES.deferredExecutionReceipts, deferred)];
|
|
try {
|
|
const service = budgetService(db);
|
|
const blocks = await Promise.all(Array.from({ length: 4 }, () => service.getInvocationBlock(companyId, agentId, { projectId })));
|
|
expect(blocks.every(block => block?.scopeType === "project" && block.scopeId === projectId)).toBe(true);
|
|
const [paused] = await db.select().from(projects).where(eq(projects.id, projectId));
|
|
expect(paused).toMatchObject({ pauseReason: alreadyPaused ? "manual" : "budget", pausedAt: alreadyPaused ? pausedAt : expect.any(Date) });
|
|
expect(dispatch).toHaveBeenCalledTimes(alreadyPaused ? 0 : 1);
|
|
expect(deferred).toHaveBeenCalledTimes(alreadyPaused ? 0 : 1);
|
|
await Promise.all(Array.from({ length: 4 }, () => service.getInvocationBlock(companyId, agentId, { projectId })));
|
|
expect((await db.select().from(projects).where(eq(projects.id, projectId)))[0]).toEqual(paused);
|
|
expect(dispatch).toHaveBeenCalledTimes(alreadyPaused ? 0 : 1);
|
|
expect(deferred).toHaveBeenCalledTimes(alreadyPaused ? 0 : 1);
|
|
} finally { unsubscribe.forEach(stop => stop()); }
|
|
});
|
|
|
|
it("hard-stops project work until a valid budget raise resumes it and overview reconciles ledger spend", async () => {
|
|
const { companyId, agentId, projectId } = await createBudgetFixture();
|
|
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
|
const service = budgetService(db, { cancelWorkForScope });
|
|
await db.insert(budgetPolicies).values({
|
|
companyId,
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
metric: "billed_cents",
|
|
windowKind: "lifetime",
|
|
amount: 100,
|
|
warnPercent: 75,
|
|
hardStopEnabled: true,
|
|
notifyEnabled: true,
|
|
isActive: true,
|
|
});
|
|
|
|
const event = await insertCostEvent({ companyId, agentId, projectId, costCents: 125 });
|
|
await service.evaluateCostEvent(event);
|
|
await service.evaluateCostEvent(event);
|
|
|
|
const incidentRows = await db
|
|
.select()
|
|
.from(budgetIncidents);
|
|
expect(incidentRows.filter((incident) => incident.thresholdType === "hard")).toHaveLength(1);
|
|
const hardIncident = incidentRows.find((incident) => incident.thresholdType === "hard")!;
|
|
expect(hardIncident).toMatchObject({
|
|
companyId,
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
amountLimit: 100,
|
|
amountObserved: 125,
|
|
status: "open",
|
|
});
|
|
|
|
const [projectAfterHardStop] = await db
|
|
.select({ pauseReason: projects.pauseReason, pausedAt: projects.pausedAt })
|
|
.from(projects);
|
|
expect(projectAfterHardStop?.pauseReason).toBe("budget");
|
|
expect(projectAfterHardStop?.pausedAt).toBeInstanceOf(Date);
|
|
expect(cancelWorkForScope).not.toHaveBeenCalled();
|
|
expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]).toMatchObject({ status: "active", lifecycleHolds: [] });
|
|
expect(await service.getInvocationBlock(companyId, agentId)).toBeNull();
|
|
await deliverBudgetEnforcement(db, { cancelWorkForScope }, companyId);
|
|
expect(cancelWorkForScope).toHaveBeenCalledTimes(1);
|
|
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId, createdBefore: expect.any(Date), enforcement: { policyId: expect.any(String), version: expect.any(Number) } });
|
|
|
|
const overviewWhileBlocked = await service.overview(companyId);
|
|
expect(overviewWhileBlocked.pausedProjectCount).toBe(1);
|
|
expect(overviewWhileBlocked.pendingApprovalCount).toBe(1);
|
|
expect(overviewWhileBlocked.policies[0]).toMatchObject({
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
amount: 100,
|
|
observedAmount: 125,
|
|
remainingAmount: 0,
|
|
utilizationPercent: 125,
|
|
status: "hard_stop",
|
|
paused: true,
|
|
pauseReason: "budget",
|
|
});
|
|
expect(overviewWhileBlocked.activeIncidents).toHaveLength(1);
|
|
|
|
await expect(
|
|
service.resolveIncident(
|
|
companyId,
|
|
hardIncident.id,
|
|
{ action: "raise_budget_and_resume", amount: 125 },
|
|
"board-user",
|
|
),
|
|
).rejects.toThrow("New budget must exceed current observed spend");
|
|
|
|
expect(await service.getInvocationBlock(companyId, agentId, { projectId })).toEqual({
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
scopeName: "Budget Project",
|
|
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
|
|
});
|
|
|
|
const resolved = await service.resolveIncident(
|
|
companyId,
|
|
hardIncident.id,
|
|
{ action: "raise_budget_and_resume", amount: 175, decisionNote: "Approved release-gate budget raise." },
|
|
"board-user",
|
|
);
|
|
expect(resolved).toMatchObject({ status: "resolved", approvalStatus: "approved" });
|
|
|
|
const [projectAfterResume] = await db
|
|
.select({ pauseReason: projects.pauseReason, pausedAt: projects.pausedAt })
|
|
.from(projects);
|
|
expect(projectAfterResume).toEqual({ pauseReason: null, pausedAt: null });
|
|
expect(await service.getInvocationBlock(companyId, agentId, { projectId })).toBeNull();
|
|
|
|
const overviewAfterResume = await service.overview(companyId);
|
|
expect(overviewAfterResume.pausedProjectCount).toBe(0);
|
|
expect(overviewAfterResume.pendingApprovalCount).toBe(0);
|
|
expect(overviewAfterResume.policies[0]).toMatchObject({
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
amount: 175,
|
|
observedAmount: 125,
|
|
remainingAmount: 50,
|
|
utilizationPercent: expect.closeTo(71.43, 2),
|
|
status: "ok",
|
|
paused: false,
|
|
pauseReason: null,
|
|
});
|
|
expect(overviewAfterResume.activeIncidents).toHaveLength(0);
|
|
});
|
|
|
|
it("discards stale undelivered enforcement when a budget is raised before dispatch", async () => {
|
|
const { companyId, agentId, projectId } = await createBudgetFixture();
|
|
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
|
const service = budgetService(db, { cancelWorkForScope });
|
|
await service.upsertPolicy(companyId, { scopeType: "project", scopeId: projectId,
|
|
amount: 100, windowKind: "lifetime" }, "board");
|
|
await service.evaluateCostEvent(await insertCostEvent({ companyId, agentId, projectId, costCents: 125 }));
|
|
expect(await hasPendingBudgetEnforcement(db)).toBe(true);
|
|
expect(cancelWorkForScope).not.toHaveBeenCalled();
|
|
await service.upsertPolicy(companyId, { scopeType: "project", scopeId: projectId,
|
|
amount: 200, windowKind: "lifetime" }, "board");
|
|
await deliverBudgetEnforcement(db, { cancelWorkForScope }, companyId);
|
|
expect(cancelWorkForScope).not.toHaveBeenCalled();
|
|
expect(await hasPendingBudgetEnforcement(db)).toBe(false);
|
|
expect(await service.getInvocationBlock(companyId, agentId, { projectId })).toBeNull();
|
|
});
|
|
|
|
it("retains failed cancellation delivery and retries without replaying accounting", async () => {
|
|
const { companyId, agentId, projectId } = await createBudgetFixture();
|
|
const service = budgetService(db);
|
|
await service.upsertPolicy(companyId, { scopeType: "project", scopeId: projectId,
|
|
amount: 100, windowKind: "lifetime" }, "board");
|
|
const event = await insertCostEvent({ companyId, agentId, projectId, costCents: 125 });
|
|
await service.evaluateCostEvent(event);
|
|
const cancelWorkForScope = vi.fn().mockRejectedValueOnce(new Error("Controlled stop delivery failure")).mockResolvedValue(undefined);
|
|
await deliverBudgetEnforcement(db, { cancelWorkForScope }, companyId);
|
|
expect(await hasPendingBudgetEnforcement(db)).toBe(true);
|
|
expect((await db.select().from(projects).where(eq(projects.id, projectId)))[0].pauseReason).toBe("budget");
|
|
const [afterFailure] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.companyId, companyId));
|
|
expect(afterFailure.enforcementVersion).toBeGreaterThan(afterFailure.enforcementDeliveredVersion);
|
|
await deliverBudgetEnforcement(db, { cancelWorkForScope }, companyId);
|
|
expect(cancelWorkForScope).toHaveBeenCalledTimes(2);
|
|
expect(await hasPendingBudgetEnforcement(db)).toBe(false);
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, companyId))).toHaveLength(1);
|
|
expect((await db.select().from(budgetIncidents)).filter(incident => incident.thresholdType === "hard")).toHaveLength(1);
|
|
});
|
|
|
|
});
|