mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Adapter execution and retry classification decide whether agent work pauses, retries, or recovers automatically > - Transient provider failures need to be classified precisely so Paperclip does not convert retryable upstream conditions into false hard failures > - At the same time, operators need an up-to-date model list for Codex-backed agents and prompts should nudge agents toward targeted verification instead of repo-wide sweeps > - This pull request tightens transient recovery classification for Claude and Codex, updates the agent prompt guidance, and adds Codex model refresh support end-to-end > - The benefit is better automatic retry behavior plus fresher operator-facing model configuration ## What Changed - added Codex usage-limit retry-window parsing and Claude extra-usage transient classification - normalized the heartbeat transient-recovery contract across adapter executions and heartbeat scheduling - documented that deferred comment wakes only reopen completed issues for human/comment-reopen interactions, while system follow-ups leave closed work closed - updated adapter-utils prompt guidance to prefer targeted verification - added Codex model refresh support in the server route, registry, shared types, and agent config form - added adapter/server tests covering the new parsing, retry scheduling, and model-refresh behavior ## Verification - `pnpm exec vitest run --project @paperclipai/adapter-utils packages/adapter-utils/src/server-utils.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-claude-local packages/adapters/claude-local/src/server/parse.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-codex-local packages/adapters/codex-local/src/server/parse.test.ts` - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/adapter-model-refresh-routes.test.ts server/src/__tests__/adapter-models.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/codex-local-execute.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-retry-scheduling.test.ts` ## Risks - Moderate behavior risk: retry classification affects whether runs auto-recover or block, so mistakes here could either suppress needed retries or over-retry real failures - Low workflow risk: deferred comment wake reopening is intentionally scoped to human/comment-reopen interactions so system follow-ups do not revive completed issues unexpectedly > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex GPT-5-based coding agent with tool use and code execution in the Codex CLI environment ## 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 - [ ] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
231 lines
6.7 KiB
TypeScript
231 lines
6.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
companySkills,
|
|
createDb,
|
|
documents,
|
|
documentRevisions,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueDocuments,
|
|
issueExecutionDecisions,
|
|
issueReadStates,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { agentService } from "../services/agents.ts";
|
|
import { companyService } from "../services/companies.ts";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping cleanup removal service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("cleanup removal services", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cleanup-removal-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(issueReadStates);
|
|
await db.delete(issueComments);
|
|
await db.delete(issueExecutionDecisions);
|
|
await db.delete(documentRevisions);
|
|
await db.delete(documents);
|
|
await db.delete(companySkills);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(issues);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedFixture() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Regression fixture",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
createdByUserId: "user-1",
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "completed",
|
|
contextSnapshot: { issueId },
|
|
});
|
|
|
|
return { agentId, companyId, issueId, runId };
|
|
}
|
|
|
|
it("removes agent-owned issue comments and run-linked activity before deleting the agent", async () => {
|
|
const { agentId, companyId, issueId, runId } = await seedFixture();
|
|
|
|
await db.insert(issueComments).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: agentId,
|
|
body: "Agent-authored comment",
|
|
});
|
|
|
|
await db.insert(activityLog).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: agentId,
|
|
action: "heartbeat.completed",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
runId,
|
|
details: {},
|
|
});
|
|
|
|
await db.insert(issueExecutionDecisions).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
issueId,
|
|
stageId: randomUUID(),
|
|
stageType: "review",
|
|
actorAgentId: agentId,
|
|
outcome: "approved",
|
|
body: "Looks good",
|
|
createdByRunId: runId,
|
|
});
|
|
|
|
const removed = await agentService(db).remove(agentId);
|
|
|
|
expect(removed?.id).toBe(agentId);
|
|
await expect(db.select().from(agents).where(eq(agents.id, agentId))).resolves.toHaveLength(0);
|
|
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0);
|
|
await expect(db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).resolves.toHaveLength(0);
|
|
await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0);
|
|
});
|
|
|
|
it("removes issue read states and activity rows before deleting the company", async () => {
|
|
const { companyId, issueId, runId } = await seedFixture();
|
|
const documentId = randomUUID();
|
|
const revisionId = randomUUID();
|
|
|
|
await db.insert(issueReadStates).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
issueId,
|
|
userId: "user-1",
|
|
});
|
|
|
|
await db.insert(companySkills).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
key: "paperclipai/paperclip/paperclip",
|
|
slug: "paperclip",
|
|
name: "Paperclip",
|
|
markdown: "# Paperclip",
|
|
});
|
|
|
|
await db.insert(activityLog).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
actorType: "system",
|
|
actorId: "system",
|
|
action: "run.created",
|
|
entityType: "run",
|
|
entityId: runId,
|
|
runId,
|
|
details: {},
|
|
});
|
|
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Run summary",
|
|
latestBody: "body",
|
|
latestRevisionId: revisionId,
|
|
latestRevisionNumber: 1,
|
|
createdByAgentId: null,
|
|
createdByUserId: "user-1",
|
|
updatedByAgentId: null,
|
|
updatedByUserId: "user-1",
|
|
});
|
|
|
|
await db.insert(issueDocuments).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "summary",
|
|
});
|
|
|
|
await db.insert(documentRevisions).values({
|
|
id: revisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 1,
|
|
title: "Run summary",
|
|
format: "markdown",
|
|
body: "body",
|
|
createdByAgentId: null,
|
|
createdByUserId: "user-1",
|
|
createdByRunId: runId,
|
|
});
|
|
|
|
const removed = await companyService(db).remove(companyId);
|
|
|
|
expect(removed?.id).toBe(companyId);
|
|
await expect(db.select().from(companies).where(eq(companies.id, companyId))).resolves.toHaveLength(0);
|
|
await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toHaveLength(0);
|
|
await expect(db.select().from(documents).where(eq(documents.id, documentId))).resolves.toHaveLength(0);
|
|
await expect(db.select().from(documentRevisions).where(eq(documentRevisions.id, revisionId))).resolves.toHaveLength(0);
|
|
await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0);
|
|
await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0);
|
|
});
|
|
});
|