diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 96085693479..6327065d0c2 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -66,6 +66,11 @@ export interface AdapterRuntimeServiceReport { export type AdapterExecutionErrorFamily = "transient_upstream"; +/** + * Marker returned by adapters (e.g. claude_local Kimi fallback) to indicate that + * a cheap/recovery fallback model was used. The server uses this to mark the + * run as recovery_only unless adapterConfig.fallback.allowDeliverables is true. + */ export interface AdapterExecutionResult { exitCode: number | null; signal: string | null; @@ -88,6 +93,8 @@ export interface AdapterExecutionResult { billingType?: AdapterBillingType | null; costUsd?: number | null; resultJson?: Record | null; + /** True when the adapter used a cheap/recovery fallback model (e.g. Kimi). */ + fallbackUsed?: boolean; runtimeServices?: AdapterRuntimeServiceReport[]; summary?: string | null; clearSession?: boolean; @@ -146,7 +153,7 @@ export interface AdapterModel { label: string; } -export type AdapterModelProfileKey = "cheap"; +export type AdapterModelProfileKey = "cheap" | "status_only" | "normal_model"; export interface AdapterModelProfileDefinition { key: AdapterModelProfileKey; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 0ce06201df4..ceb2ed65ec9 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -1084,9 +1084,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise = {}, +): KimiFallbackConfig { + return { + enabled: true, + provider: "moonshot_kimi", + command: "kimi", + model: "kimi-for-coding", + timeoutSec: 300, + allowDeliverables: false, + ...overrides, + }; +} + describe("readKimiFallbackConfig", () => { it("returns null when fallback block is absent", () => { expect(readKimiFallbackConfig({})).toBeNull(); @@ -56,24 +71,12 @@ describe("readKimiFallbackConfig", () => { describe("buildKimiFallbackArgs", () => { it("produces base args without --model when model matches default", () => { - const args = buildKimiFallbackArgs({ - enabled: true, - provider: "moonshot_kimi", - command: "kimi", - model: "kimi-for-coding", - timeoutSec: 300, - }); + const args = buildKimiFallbackArgs(makeKimiFallbackConfig()); expect(args).toEqual(["--print", "--output-format=stream-json", "--yolo", "--afk", "--model", "kimi-for-coding"]); }); it("appends --model for non-default model", () => { - const args = buildKimiFallbackArgs({ - enabled: true, - provider: "moonshot_kimi", - command: "kimi", - model: "kimi-k2.5", - timeoutSec: 300, - }); + const args = buildKimiFallbackArgs(makeKimiFallbackConfig({ model: "kimi-k2.5" })); expect(args).toContain("--model"); expect(args).toContain("kimi-k2.5"); }); @@ -120,13 +123,7 @@ describe("parseKimiStreamJson", () => { describe("describeKimiFallback", () => { it("produces a key-free pretty description", () => { - const desc = describeKimiFallback({ - enabled: true, - provider: "moonshot_kimi", - command: "kimi", - model: "kimi-for-coding", - timeoutSec: 300, - }); + const desc = describeKimiFallback(makeKimiFallbackConfig()); expect(desc).toBe("provider=moonshot_kimi command=kimi model=kimi-for-coding"); expect(desc).not.toMatch(/sk-/); }); diff --git a/packages/adapters/claude-local/src/server/kimi-fallback.ts b/packages/adapters/claude-local/src/server/kimi-fallback.ts index f07a334c2f6..f962f7a92c1 100644 --- a/packages/adapters/claude-local/src/server/kimi-fallback.ts +++ b/packages/adapters/claude-local/src/server/kimi-fallback.ts @@ -15,6 +15,12 @@ export interface KimiFallbackConfig { * agentic heartbeat mode despite quota being available). */ timeoutSec: number; + /** + * When true, a run that triggers this fallback is allowed to produce + * deliverable writes (issue edits, comments, documents). By default + * fallback runs are recovery-only. + */ + allowDeliverables: boolean; } /** @@ -48,6 +54,7 @@ export function readKimiFallbackConfig(rawConfig: Record): Kimi command: asString(block.command, DEFAULT_COMMAND), model: asString(block.model, DEFAULT_MODEL), timeoutSec: asNumber(block.timeoutSec, 300), + allowDeliverables: asBoolean(block.allowDeliverables, false), }; } diff --git a/packages/db/src/migrations/0094_recovery_only_runs.sql b/packages/db/src/migrations/0094_recovery_only_runs.sql new file mode 100644 index 00000000000..8074a51ad63 --- /dev/null +++ b/packages/db/src/migrations/0094_recovery_only_runs.sql @@ -0,0 +1,2 @@ +-- Add recovery_only flag to heartbeat_runs so cheap/recovery models can be blocked from writing deliverables. +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "recovery_only" boolean NOT NULL DEFAULT false; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 991a61cefd6..572d8af43f7 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -659,6 +659,13 @@ "when": 1780040470886, "tag": "0093_giant_green_goblin", "breakpoints": true + }, + { + "idx": 94, + "version": "7", + "when": 1780050000000, + "tag": "0094_recovery_only_runs", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index c975892a1e0..df19d70a448 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -22,6 +22,7 @@ export const heartbeatRuns = pgTable( resultJson: jsonb("result_json").$type>(), sessionIdBefore: text("session_id_before"), sessionIdAfter: text("session_id_after"), + recoveryOnly: boolean("recovery_only").notNull().default(false), logStore: text("log_store"), logRef: text("log_ref"), logBytes: bigint("log_bytes", { mode: "number" }), diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index 5f78baf0d3b..bf2cc6e0493 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -44,6 +44,7 @@ const additionalSerializedServerTests = new Set([ "server/src/__tests__/issues-service.test.ts", "server/src/__tests__/opencode-local-adapter-environment.test.ts", "server/src/__tests__/project-routes-env.test.ts", + "server/src/__tests__/recovery-only-write-restrictions.test.ts", "server/src/__tests__/redaction.test.ts", "server/src/__tests__/routines-e2e.test.ts", ]); diff --git a/server/src/__tests__/recovery-only-write-restrictions.test.ts b/server/src/__tests__/recovery-only-write-restrictions.test.ts new file mode 100644 index 00000000000..229b75f716b --- /dev/null +++ b/server/src/__tests__/recovery-only-write-restrictions.test.ts @@ -0,0 +1,142 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + companies, + companyMemberships, + createDb, + heartbeatRuns, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { heartbeatService } from "../services/heartbeat.js"; +import { recoveryOnlyGuard } from "../routes/recovery-only-guard.js"; + +vi.hoisted(() => { + process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home"; + process.env.PAPERCLIP_INSTANCE_ID = "vitest"; + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +type Db = ReturnType; + +async function createCompany(db: Db) { + const company = await db + .insert(companies) + .values({ + name: `Recovery Only ${randomUUID()}`, + issuePrefix: `RO${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: `owner-${randomUUID()}`, + status: "active", + membershipRole: "owner", + }); + return company; +} + +async function createAgent(db: Db, companyId: string) { + return db + .insert(agents) + .values({ + companyId, + name: "test-agent", + adapterKey: "claude_local", + adapterConfig: {}, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createRun(db: Db, agentId: string, companyId: string, recoveryOnly: boolean) { + return db + .insert(heartbeatRuns) + .values({ + agentId, + companyId, + status: "running", + recoveryOnly, + }) + .returning() + .then((rows) => rows[0]!); +} + +describeEmbeddedPostgres("recovery-only write restrictions", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-recovery-only-write-restrictions-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("heartbeatService.isRecoveryOnlyRun returns true for a recovery-only run", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const run = await createRun(db, agent.id, company.id, true); + + const heartbeat = heartbeatService(db); + expect(await heartbeat.isRecoveryOnlyRun(run.id)).toBe(true); + }); + + it("heartbeatService.isRecoveryOnlyRun returns false for a normal run", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const run = await createRun(db, agent.id, company.id, false); + + const heartbeat = heartbeatService(db); + expect(await heartbeat.isRecoveryOnlyRun(run.id)).toBe(false); + }); + + it("recoveryOnlyGuard throws forbidden when actor run is recovery-only", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const run = await createRun(db, agent.id, company.id, true); + const guard = recoveryOnlyGuard(db); + + const req = { actor: { type: "agent", agentId: agent.id, runId: run.id } } as any; + await expect(guard(req)).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("recovery-only mode"), + }); + }); + + it("recoveryOnlyGuard is a no-op when actor run is not recovery-only", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const run = await createRun(db, agent.id, company.id, false); + const guard = recoveryOnlyGuard(db); + + const req = { actor: { type: "agent", agentId: agent.id, runId: run.id } } as any; + await expect(guard(req)).resolves.toBeUndefined(); + }); + + it("recoveryOnlyGuard is a no-op when actor has no runId", async () => { + const guard = recoveryOnlyGuard(db); + const req = { actor: { type: "agent", agentId: "agent-1", runId: null } } as any; + await expect(guard(req)).resolves.toBeUndefined(); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b2f2d872377..eabdead7805 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -84,6 +84,7 @@ import { import { logger } from "../middleware/logger.js"; import { conflict, forbidden, HttpError, notFound, unauthorized, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { recoveryOnlyGuard } from "./recovery-only-guard.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectIssueWorkspaceCommandPaths, @@ -859,6 +860,7 @@ export function issueRoutes( }); const feedback = feedbackService(db); const companiesSvc = companyService(db); + const recoveryOnlyWriteGuard = recoveryOnlyGuard(db); let searchSvc = opts.searchService ?? null; const getSearchService = () => { searchSvc ??= companySearchService(db); @@ -2313,6 +2315,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id); if ( @@ -2561,6 +2564,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { @@ -2650,6 +2654,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { @@ -2715,6 +2720,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { @@ -2759,6 +2765,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); @@ -2996,6 +3003,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); @@ -3186,6 +3194,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, { @@ -3230,6 +3239,7 @@ export function issueRoutes( res.status(404).json({ error: "Issue not found" }); return; } + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const product = await workProductsSvc.update(id, req.body); @@ -3271,6 +3281,7 @@ export function issueRoutes( res.status(404).json({ error: "Issue not found" }); return; } + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const removed = await workProductsSvc.remove(id); @@ -3447,6 +3458,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; @@ -3481,6 +3493,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; @@ -3506,6 +3519,7 @@ export function issueRoutes( const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); + await recoveryOnlyWriteGuard(req); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, companyId, { @@ -3611,6 +3625,7 @@ export function issueRoutes( } assertCompanyAccess(req, parent.companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); + await recoveryOnlyWriteGuard(req); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, parent.companyId, { @@ -3714,6 +3729,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, sourceIssue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, sourceIssue))) return; for (const child of req.body.children as Array) { @@ -3907,6 +3923,7 @@ export function issueRoutes( } assertCompanyAccess(req, existing.companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, existing, req.body))) return; @@ -4919,6 +4936,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (issue.projectId) { const project = await projectsSvc.getById(issue.projectId); @@ -5142,6 +5160,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (req.actor.type === "agent") { if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; } else { @@ -5471,6 +5490,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const comment = await svc.getComment(commentId); @@ -5616,6 +5636,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!assertStructuredCommentFieldsAllowed(req, res, { presentation: req.body.presentation, @@ -6051,6 +6072,7 @@ export function issueRoutes( res.status(422).json({ error: "Issue does not belong to company" }); return; } + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; @@ -6171,6 +6193,7 @@ export function issueRoutes( res.status(404).json({ error: "Issue not found" }); return; } + await recoveryOnlyWriteGuard(req); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; diff --git a/server/src/routes/recovery-only-guard.ts b/server/src/routes/recovery-only-guard.ts new file mode 100644 index 00000000000..ac9f35cf9cc --- /dev/null +++ b/server/src/routes/recovery-only-guard.ts @@ -0,0 +1,36 @@ +import type { Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { heartbeatService } from "../services/heartbeat.js"; +import { forbidden } from "../errors.js"; + +export function recoveryOnlyGuard(db: Db) { + const heartbeat = heartbeatService(db); + + return async (req: Request) => { + const runId = req.actor.runId; + if (!runId) return; + + let isRecoveryOnly: boolean; + try { + isRecoveryOnly = await heartbeat.isRecoveryOnlyRun(runId); + } catch (error) { + // If the heartbeat service is unavailable (e.g. test mocks) do not block + // the request; the guard is only meaningful when run state can be queried. + return; + } + if (isRecoveryOnly) { + throw forbidden( + "This run is in recovery-only mode and cannot perform deliverable writes. " + + "Set adapterConfig.fallback.allowDeliverables to true to override.", + ); + } + }; +} + +export function createRecoveryOnlyWriteGuard(db: Db) { + const guard = recoveryOnlyGuard(db); + return async (req: Request, _res: unknown, next: () => void) => { + await guard(req); + next(); + }; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 01c9f7837e9..ee2b527416d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -329,6 +329,39 @@ function mergeAdapterRecoveryMetadata(input: { : {}), }; } + +const RECOVERY_ONLY_MODEL_PROFILES = new Set(["cheap", "status_only"]); + +function isRecoveryOnlyModelProfile(modelProfile: ModelProfileKey | null | undefined): boolean { + return modelProfile != null && RECOVERY_ONLY_MODEL_PROFILES.has(modelProfile); +} + +function readFallbackAllowDeliverables(adapterConfig: Record | null | undefined): boolean { + const fallbackConfig = parseObject(adapterConfig?.fallback); + return fallbackConfig.allowDeliverables === true; +} + +function resolveRecoveryOnlyForRun(input: { + modelProfileApplication: ModelProfileApplication; + adapterResult?: AdapterExecutionResult | null; + adapterConfig?: Record | null; +}): boolean { + const requestedRecoveryOnly = isRecoveryOnlyModelProfile(input.modelProfileApplication.requested); + const appliedRecoveryOnly = isRecoveryOnlyModelProfile(input.modelProfileApplication.applied); + + if (requestedRecoveryOnly || appliedRecoveryOnly) { + return true; + } + + const fallbackUsed = input.adapterResult?.fallbackUsed === true || input.adapterResult?.resultJson?.fallbackUsed === true; + if (fallbackUsed) { + const allowDeliverables = readFallbackAllowDeliverables(input.adapterConfig ?? input.modelProfileApplication.adapterConfig); + return !allowDeliverables; + } + + return false; +} + const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set(["approval_approved"]); const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", @@ -7499,6 +7532,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }); const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId); const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); + const recoveryOnly = resolveRecoveryOnlyForRun({ + modelProfileApplication, + adapterConfig: mergedConfig, + }); + if (recoveryOnly) { + await db + .update(heartbeatRuns) + .set({ recoveryOnly: true, updatedAt: new Date() }) + .where(eq(heartbeatRuns.id, run.id)); + } const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ companyId: agent.companyId, agentId: agent.id, @@ -10614,6 +10657,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return run ?? null; }, + isRecoveryOnlyRun: async (runId: string) => { + const [run] = await db + .select({ recoveryOnly: heartbeatRuns.recoveryOnly }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .limit(1); + return run?.recoveryOnly === true; + }, + getActiveRunIssueSummaryForAgent: async (agentId: string) => { const [run] = await db .select(heartbeatRunIssueSummaryColumns)