Files
paperclip/server/src/services/companies.ts
T
Devin Foley 93206f73fa fix: Stop archived companies from waking agents (#7478)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent has a heartbeat scheduler that wakes it on timers and on
events; every wake spawns an adapter (Claude / Codex / …) run that bills
the operator's subscription
> - When an operator archives a company, the agents inside it remain in
invokable states — the heartbeat scheduler never consults company status
— so timers keep firing and event-driven wakes (comments, mentions,
blockers-resolved, etc.) keep cascading
> - On real deployments this silently drains the operator's
subscription: idle archived companies wake their CEOs hourly, plus any
cross-company event cascade
> - This pull request enforces "archived ⇒ never spawns a run" as a
structural invariant by guarding the wake path AND cascading agent state
on archive/reactivate
> - The benefit is that archived companies stop billing the operator,
and the UI/queue stays consistent with the invariant

## What Changed

- `server/src/services/heartbeat.ts`:
- `enqueueWakeup()` loads the company and short-circuits when status is
not `active`. Background sources (timer, automation, events) write a
`company.inactive` skipped wake and return `null`; explicit user invokes
throw a `conflict` so the UI surfaces the real reason.
- `tickTimers()` joins agents to active companies so the scheduler does
not iterate archived-company agents at all (no skip-row noise).
- `server/src/services/companies.ts`:
- `archive(id, actor?)` pauses runnable agents with `pauseReason =
"company_archived"` inside the transaction (preserving
`pending_approval`, `terminated`, and agents paused for unrelated
reasons), then cancels `queued`/`running` heartbeat runs after the
transaction commits.
- `update(id, data, actor?)` reverses the cascade only for agents whose
`pauseReason === "company_archived"` on the `archived → active`
transition; manually-paused agents stay paused.
- Both methods emit activity-log entries (`company.archived` with
`agentsPaused` + `runsCancelled`, `company.reactivated` with
`agentsRestored`) so the audit trail fires regardless of caller.
- `packages/shared/src/constants.ts` + `server/src/services/budgets.ts`:
add `company_archived` to the legal `PauseReason` union so the
restorable marker is a first-class value.
-
`packages/db/src/migrations/0094_backfill_archived_company_agent_pauses.sql`:
backfill so existing archived-company agents become `paused /
company_archived` (excludes `pending_approval`).
- `ui/src/lib/activity-format.ts`: add the `company.reactivated` label.

## Verification

- `npx vitest run src/__tests__/companies-service.test.ts` — archive
cascade, reactivate cascade, and activity-log entries (with counts) all
pass.
- `npx vitest run
src/__tests__/heartbeat-archived-company-guard.test.ts` — timer +
on-demand + event-wake paths all blocked for archived companies;
`company.inactive` skipped-wake row written; user-initiated wakes throw
`conflict`.
- `pnpm typecheck` — clean.
- Manual repro from the bug description: archive a company, wait an
interval / post a comment on one of its issues, observe zero new
heartbeat runs.

## Risks

- Migration `0094` is a single bulk UPDATE on `agents` joined to
archived `companies`. On large deployments it briefly holds row locks on
archived-company agent rows; should be quick because the predicate is
narrow (`status NOT IN (paused, terminated, pending_approval)` and
`companies.status = 'archived'`).
- New `pauseReason` value (`company_archived`) is opaque to older
clients that only know the previous union. Acceptable because the union
is read as plain text and the contract is sync'd in the same change.
- Behavior change for users: invoking an agent in an archived company
now fails with a conflict instead of silently spawning a run. Intended.

## Model Used

- Claude (Anthropic) — model `claude-opus-4-7` ("Opus 4.7"), Claude Code
CLI, with tool use (Read/Edit/Bash/Grep). No extended thinking mode.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A, no UI changes beyond an activity-log label string
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Related Work

Fixes #1348 (`[Bug] Archived companies still running heartbeats and
consuming tokens`).

Prior attempts and parallel work in this area:

- #1365 and #1429 by @hungdqdesign (March 2026) — both closed without
merging. Same three-layer shape (`tickTimers` / `enqueueWakeup` /
`resumeQueuedRuns` + archive-route cancellation) targeting #1348. Credit
for first publicly proposing the wake-path-guard approach.
- #5865 by @stubbi (May 2026, open) — adds the same `companies.status !=
'archived'` joins to `tickTimers`, `enqueueWakeup`, `resumeQueuedRuns`,
**and** routines `tickScheduledTriggers`, bundled with plugin-table
tenant isolation (`plugin_entities` / `plugin_job_runs` / `plugin_logs`
/ `plugin_webhook_deliveries` get a `companyId` FK with `ON DELETE
CASCADE`). This PR is narrower — it does not touch routines or plugin
tables — but adds the **archive cascade** (pause agents with
`pauseReason = "company_archived"`), the **reactivate reverse**
(un-pause only that subset), the **`company_archived` pause-reason
marker**, and a **backfill migration** for pre-existing archived
companies, which #5865 does not include. Happy to coordinate sequencing
or rebase if #5865 lands first.
2026-06-03 21:07:14 -07:00

496 lines
17 KiB
TypeScript

import { and, count, eq, gte, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
companies,
companyLogos,
assets,
agents,
agentApiKeys,
agentRuntimeState,
agentTaskSessions,
agentWakeupRequests,
issues,
issueComments,
projects,
goals,
heartbeatRuns,
heartbeatRunEvents,
costEvents,
financeEvents,
issueReadStates,
approvalComments,
approvals,
activityLog,
companySecrets,
joinRequests,
invites,
principalPermissionGrants,
companyMemberships,
companySkills,
documents,
} from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { environmentService } from "./environments.js";
import { heartbeatService } from "./heartbeat.js";
import { logActivity } from "./activity-log.js";
export interface CompanyActivityActor {
actorType: "user" | "agent" | "system" | "plugin";
actorId: string;
agentId?: string | null;
runId?: string | null;
}
const SYSTEM_COMPANY_ACTOR: CompanyActivityActor = {
actorType: "system",
actorId: "system",
agentId: null,
runId: null,
};
export function companyService(db: Db) {
const ISSUE_PREFIX_FALLBACK = "CMP";
const environmentsSvc = environmentService(db);
const heartbeat = heartbeatService(db);
type CompanyTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
async function applyArchiveCascadeInTx(tx: CompanyTx, id: string) {
const pausedAgentRows = await tx
.update(agents)
.set({
status: "paused",
pauseReason: "company_archived",
pausedAt: new Date(),
updatedAt: new Date(),
})
.where(and(
eq(agents.companyId, id),
notInArray(agents.status, ["paused", "terminated", "pending_approval"]),
))
.returning({ id: agents.id });
const activeRunIds = await tx
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.companyId, id),
inArray(heartbeatRuns.status, ["queued", "running"]),
))
.then((rows) => rows.map((row) => row.id));
await tx
.update(agentWakeupRequests)
.set({
status: "cancelled",
error: "Cancelled because the company was archived",
finishedAt: new Date(),
updatedAt: new Date(),
})
.where(and(
eq(agentWakeupRequests.companyId, id),
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]),
isNull(agentWakeupRequests.runId),
));
return { agentsPaused: pausedAgentRows.length, activeRunIds };
}
async function finalizeArchive(
id: string,
actor: CompanyActivityActor,
cascade: { agentsPaused: number; activeRunIds: string[] },
) {
for (const runId of cascade.activeRunIds) {
await heartbeat.cancelRun(runId, "Cancelled because the company was archived");
}
await logActivity(db, {
companyId: id,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId ?? null,
runId: actor.runId ?? null,
action: "company.archived",
entityType: "company",
entityId: id,
details: {
agentsPaused: cascade.agentsPaused,
runsCancelled: cascade.activeRunIds.length,
},
});
}
const companySelection = {
id: companies.id,
name: companies.name,
description: companies.description,
status: companies.status,
issuePrefix: companies.issuePrefix,
issueCounter: companies.issueCounter,
budgetMonthlyCents: companies.budgetMonthlyCents,
spentMonthlyCents: companies.spentMonthlyCents,
attachmentMaxBytes: companies.attachmentMaxBytes,
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId,
feedbackDataSharingTermsVersion: companies.feedbackDataSharingTermsVersion,
brandColor: companies.brandColor,
logoAssetId: companyLogos.assetId,
createdAt: companies.createdAt,
updatedAt: companies.updatedAt,
};
function enrichCompany<T extends { logoAssetId: string | null }>(company: T) {
return {
...company,
logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
};
}
function currentUtcMonthWindow(now = new Date()) {
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
return {
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
};
}
async function getMonthlySpendByCompanyIds(
companyIds: string[],
database: Pick<Db, "select"> = db,
) {
if (companyIds.length === 0) return new Map<string, number>();
const { start, end } = currentUtcMonthWindow();
const rows = await database
.select({
companyId: costEvents.companyId,
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
})
.from(costEvents)
.where(
and(
inArray(costEvents.companyId, companyIds),
gte(costEvents.occurredAt, start),
lt(costEvents.occurredAt, end),
),
)
.groupBy(costEvents.companyId);
return new Map(rows.map((row) => [row.companyId, Number(row.spentMonthlyCents ?? 0)]));
}
async function hydrateCompanySpend<T extends { id: string; spentMonthlyCents: number }>(
rows: T[],
database: Pick<Db, "select"> = db,
) {
const spendByCompanyId = await getMonthlySpendByCompanyIds(rows.map((row) => row.id), database);
return rows.map((row) => ({
...row,
spentMonthlyCents: spendByCompanyId.get(row.id) ?? 0,
}));
}
function getCompanyQuery(database: Pick<Db, "select">) {
return database
.select(companySelection)
.from(companies)
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id));
}
function deriveIssuePrefixBase(name: string) {
const normalized = name.toUpperCase().replace(/[^A-Z]/g, "");
return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK;
}
function suffixForAttempt(attempt: number) {
if (attempt <= 1) return "";
return "A".repeat(attempt - 1);
}
function isIssuePrefixConflict(error: unknown) {
const seen = new Set<unknown>();
let current = error;
while (typeof current === "object" && current !== null && !seen.has(current)) {
seen.add(current);
const maybe = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown };
const constraint = maybe.constraint ?? maybe.constraint_name;
if (maybe.code === "23505" && constraint === "companies_issue_prefix_idx") {
return true;
}
current = maybe.cause;
}
return false;
}
async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) {
const base = deriveIssuePrefixBase(data.name);
let suffix = 1;
while (suffix < 10000) {
const candidate = `${base}${suffixForAttempt(suffix)}`;
try {
const rows = await db
.insert(companies)
.values({ ...data, issuePrefix: candidate })
.returning();
return rows[0];
} catch (error) {
if (!isIssuePrefixConflict(error)) throw error;
}
suffix += 1;
}
throw new Error("Unable to allocate unique issue prefix");
}
return {
list: async () => {
const rows = await getCompanyQuery(db);
const hydrated = await hydrateCompanySpend(rows);
return hydrated.map((row) => enrichCompany(row));
},
getById: async (id: string) => {
const row = await getCompanyQuery(db)
.where(eq(companies.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [hydrated] = await hydrateCompanySpend([row], db);
return enrichCompany(hydrated);
},
create: async (data: typeof companies.$inferInsert) => {
const created = await createCompanyWithUniquePrefix(data);
await environmentsSvc.ensureLocalEnvironment(created.id);
const row = await getCompanyQuery(db)
.where(eq(companies.id, created.id))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Company not found after creation");
const [hydrated] = await hydrateCompanySpend([row], db);
return enrichCompany(hydrated);
},
update: async (
id: string,
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR,
) => {
const result = await db.transaction(async (tx) => {
const existing = await getCompanyQuery(tx)
.where(eq(companies.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
const { logoAssetId, ...companyPatch } = data;
const willReactivate = existing.status !== "active" && companyPatch.status === "active";
const willArchive = existing.status !== "archived" && companyPatch.status === "archived";
if (logoAssetId !== undefined && logoAssetId !== null) {
const nextLogoAsset = await tx
.select({ id: assets.id, companyId: assets.companyId })
.from(assets)
.where(eq(assets.id, logoAssetId))
.then((rows) => rows[0] ?? null);
if (!nextLogoAsset) throw notFound("Logo asset not found");
if (nextLogoAsset.companyId !== existing.id) {
throw unprocessable("Logo asset must belong to the same company");
}
}
const updated = await tx
.update(companies)
.set({ ...companyPatch, updatedAt: new Date() })
.where(eq(companies.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
let agentsRestored = 0;
if (willReactivate) {
const restoredRows = await tx
.update(agents)
.set({
status: "idle",
pauseReason: null,
pausedAt: null,
updatedAt: new Date(),
})
.where(and(
eq(agents.companyId, id),
eq(agents.status, "paused"),
eq(agents.pauseReason, "company_archived"),
))
.returning({ id: agents.id });
agentsRestored = restoredRows.length;
}
const archiveCascade = willArchive ? await applyArchiveCascadeInTx(tx, id) : null;
if (logoAssetId === null) {
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
} else if (logoAssetId !== undefined) {
await tx
.insert(companyLogos)
.values({
companyId: id,
assetId: logoAssetId,
})
.onConflictDoUpdate({
target: companyLogos.companyId,
set: {
assetId: logoAssetId,
updatedAt: new Date(),
},
});
}
if (logoAssetId !== undefined && existing.logoAssetId && existing.logoAssetId !== logoAssetId) {
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
}
const [hydrated] = await hydrateCompanySpend([{
...updated,
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
}], tx);
const shouldLogReactivation = willReactivate &&
(existing.status === "archived" || agentsRestored > 0);
return {
company: enrichCompany(hydrated),
reactivated: shouldLogReactivation ? { agentsRestored } : null,
archiveCascade,
};
});
if (!result) return null;
if (result.reactivated) {
await logActivity(db, {
companyId: id,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId ?? null,
runId: actor.runId ?? null,
action: "company.reactivated",
entityType: "company",
entityId: id,
details: { agentsRestored: result.reactivated.agentsRestored },
});
}
if (result.archiveCascade) {
await finalizeArchive(id, actor, result.archiveCascade);
}
return result.company;
},
archive: async (id: string, actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR) => {
const result = await db.transaction(async (tx) => {
const existing = await tx
.select({ status: companies.status })
.from(companies)
.where(eq(companies.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
const wasAlreadyArchived = existing.status === "archived";
if (!wasAlreadyArchived) {
await tx
.update(companies)
.set({ status: "archived", updatedAt: new Date() })
.where(eq(companies.id, id));
}
const cascade = wasAlreadyArchived ? null : await applyArchiveCascadeInTx(tx, id);
const row = await getCompanyQuery(tx)
.where(eq(companies.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [hydrated] = await hydrateCompanySpend([row], tx);
return {
company: enrichCompany(hydrated),
cascade,
};
});
if (!result) return null;
if (result.cascade) {
await finalizeArchive(id, actor, result.cascade);
}
return result.company;
},
remove: (id: string) =>
db.transaction(async (tx) => {
// Delete from child tables in dependency order
const companyRunIds = await tx
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, id));
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
if (companyRunIds.length > 0) {
await tx
.delete(heartbeatRunEvents)
.where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
}
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await tx.delete(approvals).where(eq(approvals.companyId, id));
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
await tx.delete(invites).where(eq(invites.companyId, id));
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
await tx.delete(companySkills).where(eq(companySkills.companyId, id));
await tx.delete(issueReadStates).where(eq(issueReadStates.companyId, id));
await tx.delete(documents).where(eq(documents.companyId, id));
await tx.delete(issues).where(eq(issues.companyId, id));
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
await tx.delete(assets).where(eq(assets.companyId, id));
await tx.delete(goals).where(eq(goals.companyId, id));
await tx.delete(projects).where(eq(projects.companyId, id));
await tx.delete(agents).where(eq(agents.companyId, id));
const rows = await tx
.delete(companies)
.where(eq(companies.id, id))
.returning();
return rows[0] ?? null;
}),
stats: () =>
Promise.all([
db
.select({ companyId: agents.companyId, count: count() })
.from(agents)
.groupBy(agents.companyId),
db
.select({ companyId: issues.companyId, count: count() })
.from(issues)
.groupBy(issues.companyId),
]).then(([agentRows, issueRows]) => {
const result: Record<string, { agentCount: number; issueCount: number }> = {};
for (const row of agentRows) {
result[row.companyId] = { agentCount: row.count, issueCount: 0 };
}
for (const row of issueRows) {
if (result[row.companyId]) {
result[row.companyId].issueCount = row.count;
} else {
result[row.companyId] = { agentCount: 0, issueCount: row.count };
}
}
return result;
}),
};
}