Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/adapter-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -88,6 +93,8 @@ export interface AdapterExecutionResult {
billingType?: AdapterBillingType | null;
costUsd?: number | null;
resultJson?: Record<string, unknown> | null;
/** True when the adapter used a cheap/recovery fallback model (e.g. Kimi). */
fallbackUsed?: boolean;
runtimeServices?: AdapterRuntimeServiceReport[];
summary?: string | null;
clearSession?: boolean;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion packages/adapters/claude-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,9 +1084,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
costUsd: 0,
resultJson: {
summary: fallbackParsed.summary,
fallbackUsed: "moonshot_kimi",
fallbackUsed: true,
fallbackProvider: "moonshot_kimi",
fallbackTriggeredByError: initialErrorMessage || null,
},
fallbackUsed: true,
summary: fallbackParsed.summary,
clearSession: true,
};
Expand Down
39 changes: 18 additions & 21 deletions packages/adapters/claude-local/src/server/kimi-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,23 @@ import {
buildKimiFallbackArgs,
parseKimiStreamJson,
describeKimiFallback,
type KimiFallbackConfig,
} from "./kimi-fallback.js";

function makeKimiFallbackConfig(
overrides: Partial<KimiFallbackConfig> = {},
): 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();
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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-/);
});
Expand Down
7 changes: 7 additions & 0 deletions packages/adapters/claude-local/src/server/kimi-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -48,6 +54,7 @@ export function readKimiFallbackConfig(rawConfig: Record<string, unknown>): Kimi
command: asString(block.command, DEFAULT_COMMAND),
model: asString(block.model, DEFAULT_MODEL),
timeoutSec: asNumber(block.timeoutSec, 300),
allowDeliverables: asBoolean(block.allowDeliverables, false),
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/migrations/0094_recovery_only_runs.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions packages/db/src/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
1 change: 1 addition & 0 deletions packages/db/src/schema/heartbeat_runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const heartbeatRuns = pgTable(
resultJson: jsonb("result_json").$type<Record<string, unknown>>(),
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" }),
Expand Down
1 change: 1 addition & 0 deletions scripts/run-vitest-stable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
Expand Down
142 changes: 142 additions & 0 deletions server/src/__tests__/recovery-only-write-restrictions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createDb>;

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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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();
});
});
Loading