From e0d2c4bddf95a85bd652d28603e789c1a00b3a1f Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 07:39:24 -0500 Subject: [PATCH 01/15] Ignore .paperclip in dev restart detection Co-Authored-By: Paperclip --- scripts/dev-runner.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index a0910430..7e13efef 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -16,7 +16,6 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); const watchedDirectories = [ - ".paperclip", "cli", "scripts", "server", From 5a1e17f27fb681d8fd5ad127b80f323ffeeffa5e Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 07:44:11 -0500 Subject: [PATCH 02/15] Fix issue workspace reuse after isolation Persist realized isolated/operator workspaces back onto the issue as reusable workspaces so later runs stay on the same workspace, and update the issue workspace picker to present realized isolated workspaces as existing workspaces. Co-Authored-By: Paperclip --- .../execution-workspace-policy.test.ts | 9 +++++ .../services/execution-workspace-policy.ts | 12 +++++++ server/src/services/heartbeat.ts | 29 +++++++++++++--- ui/src/components/IssueProperties.tsx | 33 ++++++++++++++----- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index a52fba4e..71ef6192 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -3,6 +3,7 @@ import { buildExecutionWorkspaceAdapterConfig, defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, @@ -142,6 +143,14 @@ describe("execution workspace policy helpers", () => { }); }); + it("maps persisted execution workspace modes back to issue settings", () => { + expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default"); + }); + it("disables project execution workspace policy when the instance flag is off", () => { expect( gateProjectExecutionWorkspacePolicy( diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 53487324..4f79beb3 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -132,6 +132,18 @@ export function defaultIssueExecutionWorkspaceSettingsForProject( }; } +export function issueExecutionWorkspaceModeForPersistedWorkspace( + mode: string | null | undefined, +): IssueExecutionWorkspaceSettings["mode"] { + if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") { + return mode; + } + if (mode === "adapter_managed" || mode === "cloud_sandbox") { + return "agent_default"; + } + return "shared_workspace"; +} + export function resolveExecutionWorkspaceMode(input: { projectPolicy: ProjectExecutionWorkspacePolicy | null; issueSettings: IssueExecutionWorkspaceSettings | null; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0694efed..96a42f7e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -45,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js"; import { buildExecutionWorkspaceAdapterConfig, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, @@ -2098,11 +2099,29 @@ export function heartbeatService(db: Db) { cleanupReason: null, }); } - if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { - await issuesSvc.update(issueId, { - executionWorkspaceId: persistedExecutionWorkspace.id, - ...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}), - }); + if (issueId && persistedExecutionWorkspace) { + const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); + const shouldSwitchIssueToExistingWorkspace = + issueRef?.executionWorkspacePreference === "reuse_existing" || + executionWorkspaceMode === "isolated_workspace" || + executionWorkspaceMode === "operator_branch"; + const nextIssuePatch: Record = {}; + if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { + nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; + } + if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) { + nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId; + } + if (shouldSwitchIssueToExistingWorkspace) { + nextIssuePatch.executionWorkspacePreference = "reuse_existing"; + nextIssuePatch.executionWorkspaceSettings = { + ...(issueExecutionWorkspaceSettings ?? {}), + mode: nextIssueWorkspaceMode, + }; + } + if (Object.keys(nextIssuePatch).length > 0) { + await issuesSvc.update(issueId, nextIssuePatch); + } } if (persistedExecutionWorkspace) { context.executionWorkspaceId = persistedExecutionWorkspace.id; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 053e8e42..935a3b95 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -53,6 +53,17 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) { return "shared_workspace"; } +function shouldPresentExistingWorkspaceSelection(issue: Issue) { + const persistedMode = + issue.currentExecutionWorkspace?.mode + ?? issue.executionWorkspaceSettings?.mode + ?? issue.executionWorkspacePreference; + return Boolean( + issue.executionWorkspaceId && + (persistedMode === "isolated_workspace" || persistedMode === "operator_branch"), + ); +} + interface IssuePropertiesProps { issue: Issue; onUpdate: (data: Record) => void; @@ -268,10 +279,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp ? currentProject?.executionWorkspacePolicy ?? null : null; const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); - const currentExecutionWorkspaceSelection = - issue.executionWorkspacePreference - ?? issue.executionWorkspaceSettings?.mode - ?? defaultExecutionWorkspaceModeForProject(currentProject); const { data: reusableExecutionWorkspaces } = useQuery({ queryKey: queryKeys.executionWorkspaces.list(companyId!, { projectId: issue.projectId ?? undefined, @@ -298,9 +305,17 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp } return Array.from(seen.values()); }, [reusableExecutionWorkspaces]); - const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find( - (workspace) => workspace.id === issue.executionWorkspaceId, - ); + const selectedReusableExecutionWorkspace = + deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId) + ?? issue.currentExecutionWorkspace + ?? null; + const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue) + ? "reuse_existing" + : ( + issue.executionWorkspacePreference + ?? issue.executionWorkspaceSettings?.mode + ?? defaultExecutionWorkspaceModeForProject(currentProject) + ); const projectLink = (id: string | null) => { if (!id) return null; const project = projects?.find((p) => p.id === id) ?? null; @@ -680,7 +695,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > {EXECUTION_WORKSPACE_OPTIONS.map((option) => ( ))} From 02c779b41d6fa8aa06f01fa5f4564b5eaae9126a Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 12:20:48 -0500 Subject: [PATCH 03/15] Use issue participation for agent history Co-Authored-By: Paperclip --- server/src/__tests__/issues-service.test.ts | 284 ++++++++++++++++++++ server/src/routes/issues.ts | 1 + server/src/services/issues.ts | 29 ++ ui/src/api/issues.ts | 2 + ui/src/components/IssuesList.tsx | 11 +- ui/src/pages/AgentDetail.tsx | 14 +- ui/src/pages/Issues.tsx | 6 +- 7 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 server/src/__tests__/issues-service.test.ts diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts new file mode 100644 index 00000000..70b99ba8 --- /dev/null +++ b/server/src/__tests__/issues-service.test.ts @@ -0,0 +1,284 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + applyPendingMigrations, + companies, + createDb, + ensurePostgresDatabase, + issueComments, + issues, +} from "@paperclipai/db"; +import { issueService } from "../services/issues.ts"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +async function startTempDatabase() { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + onLog: () => {}, + onError: () => {}, + }); + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + return { connectionString, dataDir, instance }; +} + +describe("issueService.list participantAgentId", () => { + let db!: ReturnType; + let svc!: ReturnType; + let instance: EmbeddedPostgresInstance | null = null; + let dataDir = ""; + + beforeAll(async () => { + const started = await startTempDatabase(); + db = createDb(started.connectionString); + svc = issueService(db); + instance = started.instance; + dataDir = started.dataDir; + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await instance?.stop(); + if (dataDir) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("returns issues an agent participated in across the supported signals", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId, + name: "OtherAgent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + const assignedIssueId = randomUUID(); + const createdIssueId = randomUUID(); + const commentedIssueId = randomUUID(); + const activityIssueId = randomUUID(); + const excludedIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + createdByAgentId: otherAgentId, + }, + { + id: createdIssueId, + companyId, + title: "Created issue", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + { + id: commentedIssueId, + companyId, + title: "Commented issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + }, + { + id: activityIssueId, + companyId, + title: "Activity issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + }, + { + id: excludedIssueId, + companyId, + title: "Excluded issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + assigneeAgentId: otherAgentId, + }, + ]); + + await db.insert(issueComments).values({ + companyId, + issueId: commentedIssueId, + authorAgentId: agentId, + body: "Investigating this issue.", + }); + + await db.insert(activityLog).values({ + companyId, + actorType: "agent", + actorId: agentId, + action: "issue.updated", + entityType: "issue", + entityId: activityIssueId, + agentId, + details: { changed: true }, + }); + + const result = await svc.list(companyId, { participantAgentId: agentId }); + const resultIds = new Set(result.map((issue) => issue.id)); + + expect(resultIds).toEqual(new Set([ + assignedIssueId, + createdIssueId, + commentedIssueId, + activityIssueId, + ])); + expect(resultIds.has(excludedIssueId)).toBe(false); + }); + + it("combines participation filtering with search", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const matchedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: matchedIssueId, + companyId, + title: "Invoice reconciliation", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + { + id: otherIssueId, + companyId, + title: "Weekly planning", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + ]); + + const result = await svc.list(companyId, { + participantAgentId: agentId, + q: "invoice", + }); + + expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 43eebe66..073b32f7 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -233,6 +233,7 @@ export function issueRoutes(db: Db, storage: StorageService) { const result = await svc.list(companyId, { status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, + participantAgentId: req.query.participantAgentId as string | undefined, assigneeUserId, touchedByUserId, unreadForUserId, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 681da27d..ae377388 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,6 +1,7 @@ import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + activityLog, agents, assets, companies, @@ -62,6 +63,7 @@ function applyStatusSideEffects( export interface IssueFilters { status?: string; assigneeAgentId?: string; + participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; unreadForUserId?: string; @@ -134,6 +136,30 @@ function touchedByUserCondition(companyId: string, userId: string) { `; } +function participatedByAgentCondition(companyId: string, agentId: string) { + return sql` + ( + ${issues.createdByAgentId} = ${agentId} + OR ${issues.assigneeAgentId} = ${agentId} + OR EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ${issueComments.authorAgentId} = ${agentId} + ) + OR EXISTS ( + SELECT 1 + FROM ${activityLog} + WHERE ${activityLog.companyId} = ${companyId} + AND ${activityLog.entityType} = 'issue' + AND ${activityLog.entityId} = ${issues.id}::text + AND ${activityLog.agentId} = ${agentId} + ) + ) + `; +} + function myLastCommentAtExpr(companyId: string, userId: string) { return sql` ( @@ -508,6 +534,9 @@ export function issueService(db: Db) { if (filters?.assigneeAgentId) { conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); } + if (filters?.participantAgentId) { + conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); + } if (filters?.assigneeUserId) { conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); } diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 308028b3..62cb347c 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -18,6 +18,7 @@ export const issuesApi = { status?: string; projectId?: string; assigneeAgentId?: string; + participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; unreadForUserId?: string; @@ -32,6 +33,7 @@ export const issuesApi = { if (filters?.status) params.set("status", filters.status); if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); + if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId); if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 2b44a2f2..e04157b8 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -166,6 +166,9 @@ interface IssuesListProps { issueLinkState?: unknown; initialAssignees?: string[]; initialSearch?: string; + searchFilters?: { + participantAgentId?: string; + }; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -182,6 +185,7 @@ export function IssuesList({ issueLinkState, initialAssignees, initialSearch, + searchFilters, onSearchChange, onUpdateIssue, }: IssuesListProps) { @@ -239,8 +243,11 @@ export function IssuesList({ }, [scopedKey]); const { data: searchedIssues = [] } = useQuery({ - queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }), + queryKey: [ + ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), + searchFilters ?? {}, + ], + queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, }); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2c5297d0..54c0e03f 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -572,9 +572,9 @@ export function AgentDetail() { }); const { data: allIssues } = useQuery({ - queryKey: queryKeys.issues.list(resolvedCompanyId!), - queryFn: () => issuesApi.list(resolvedCompanyId!), - enabled: !!resolvedCompanyId && needsDashboardData, + queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"], + queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }), + enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData, }); const { data: allAgents } = useQuery({ @@ -592,7 +592,6 @@ export function AgentDetail() { }); const assignedIssues = (allIssues ?? []) - .filter((i) => i.assigneeAgentId === agent?.id) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated"); @@ -1174,12 +1173,15 @@ function AgentOverview({

Recent Issues

- + See All →
{assignedIssues.length === 0 ? ( -

No assigned issues.

+

No recent issues.

) : (
{assignedIssues.slice(0, 10).map((issue) => ( diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index bc5131e7..ee3d64b0 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -21,6 +21,7 @@ export function Issues() { const queryClient = useQueryClient(); const initialSearch = searchParams.get("q") ?? ""; + const participantAgentId = searchParams.get("participantAgentId") ?? undefined; const debounceRef = useRef>(undefined); const handleSearchChange = useCallback((search: string) => { clearTimeout(debounceRef.current); @@ -86,8 +87,8 @@ export function Issues() { }, [setBreadcrumbs]); const { data: issues, isLoading, error } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"], + queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }), enabled: !!selectedCompanyId, }); @@ -117,6 +118,7 @@ export function Issues() { initialSearch={initialSearch} onSearchChange={handleSearchChange} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} + searchFilters={participantAgentId ? { participantAgentId } : undefined} /> ); } From eac3f3fa69df7687400f426e2a1b11a78ef6e537 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 17:09:38 -0500 Subject: [PATCH 04/15] Honor explicit failed-run session resume Co-Authored-By: Paperclip --- .../heartbeat-workspace-session.test.ts | 53 +++++++ server/src/services/heartbeat.ts | 141 +++++++++++++++++- 2 files changed, 186 insertions(+), 8 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 79d781e9..7fab2b42 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import type { agents } from "@paperclipai/db"; +import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { + buildExplicitResumeSessionOverride, formatRuntimeWorkspaceWarningLog, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, @@ -182,6 +184,57 @@ describe("shouldResetTaskSessionForWake", () => { }); }); +describe("buildExplicitResumeSessionOverride", () => { + it("reuses saved task session params when they belong to the selected failed run", () => { + const result = buildExplicitResumeSessionOverride({ + resumeFromRunId: "run-1", + resumeRunSessionIdBefore: "session-before", + resumeRunSessionIdAfter: "session-after", + taskSession: { + sessionParamsJson: { + sessionId: "session-after", + cwd: "/tmp/project", + }, + sessionDisplayId: "session-after", + lastRunId: "run-1", + }, + sessionCodec: codexSessionCodec, + }); + + expect(result).toEqual({ + sessionDisplayId: "session-after", + sessionParams: { + sessionId: "session-after", + cwd: "/tmp/project", + }, + }); + }); + + it("falls back to the selected run session id when no matching task session params are available", () => { + const result = buildExplicitResumeSessionOverride({ + resumeFromRunId: "run-1", + resumeRunSessionIdBefore: "session-before", + resumeRunSessionIdAfter: "session-after", + taskSession: { + sessionParamsJson: { + sessionId: "other-session", + cwd: "/tmp/project", + }, + sessionDisplayId: "other-session", + lastRunId: "run-2", + }, + sessionCodec: codexSessionCodec, + }); + + expect(result).toEqual({ + sessionDisplayId: "session-after", + sessionParams: { + sessionId: "session-after", + }, + }); + }); +}); + describe("formatRuntimeWorkspaceWarningLog", () => { it("emits informational workspace warnings on stdout", () => { expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 96a42f7e..c909b9b7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -326,6 +326,51 @@ async function resolveLedgerScopeForRun( }; } +type ResumeSessionRow = { + sessionParamsJson: Record | null; + sessionDisplayId: string | null; + lastRunId: string | null; +}; + +export function buildExplicitResumeSessionOverride(input: { + resumeFromRunId: string; + resumeRunSessionIdBefore: string | null; + resumeRunSessionIdAfter: string | null; + taskSession: ResumeSessionRow | null; + sessionCodec: AdapterSessionCodec; +}) { + const desiredDisplayId = truncateDisplayId( + input.resumeRunSessionIdAfter ?? input.resumeRunSessionIdBefore, + ); + const taskSessionParams = normalizeSessionParams( + input.sessionCodec.deserialize(input.taskSession?.sessionParamsJson ?? null), + ); + const taskSessionDisplayId = truncateDisplayId( + input.taskSession?.sessionDisplayId ?? + (input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ?? + readNonEmptyString(taskSessionParams?.sessionId), + ); + const canReuseTaskSessionParams = + input.taskSession != null && + ( + input.taskSession.lastRunId === input.resumeFromRunId || + (!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId) + ); + const sessionParams = + canReuseTaskSessionParams + ? taskSessionParams + : desiredDisplayId + ? { sessionId: desiredDisplayId } + : null; + const sessionDisplayId = desiredDisplayId ?? (canReuseTaskSessionParams ? taskSessionDisplayId : null); + + if (!sessionDisplayId && !sessionParams) return null; + return { + sessionDisplayId, + sessionParams, + }; +} + function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null { if (!usage) return null; return { @@ -978,6 +1023,57 @@ export function heartbeatService(db: Db) { return runtimeForRun?.sessionId ?? null; } + async function resolveExplicitResumeSessionOverride( + agent: typeof agents.$inferSelect, + payload: Record | null, + taskKey: string | null, + ) { + const resumeFromRunId = readNonEmptyString(payload?.resumeFromRunId); + if (!resumeFromRunId) return null; + + const resumeRun = await db + .select({ + id: heartbeatRuns.id, + contextSnapshot: heartbeatRuns.contextSnapshot, + sessionIdBefore: heartbeatRuns.sessionIdBefore, + sessionIdAfter: heartbeatRuns.sessionIdAfter, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, resumeFromRunId), + eq(heartbeatRuns.companyId, agent.companyId), + eq(heartbeatRuns.agentId, agent.id), + ), + ) + .then((rows) => rows[0] ?? null); + if (!resumeRun) return null; + + const resumeContext = parseObject(resumeRun.contextSnapshot); + const resumeTaskKey = deriveTaskKey(resumeContext, null) ?? taskKey; + const resumeTaskSession = resumeTaskKey + ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey) + : null; + const sessionCodec = getAdapterSessionCodec(agent.adapterType); + const sessionOverride = buildExplicitResumeSessionOverride({ + resumeFromRunId, + resumeRunSessionIdBefore: resumeRun.sessionIdBefore, + resumeRunSessionIdAfter: resumeRun.sessionIdAfter, + taskSession: resumeTaskSession, + sessionCodec, + }); + if (!sessionOverride) return null; + + return { + resumeFromRunId, + taskKey: resumeTaskKey, + issueId: readNonEmptyString(resumeContext.issueId), + taskId: readNonEmptyString(resumeContext.taskId) ?? readNonEmptyString(resumeContext.issueId), + sessionDisplayId: sessionOverride.sessionDisplayId, + sessionParams: sessionOverride.sessionParams, + }; + } + async function resolveWorkspaceForRun( agent: typeof agents.$inferSelect, context: Record, @@ -1921,9 +2017,18 @@ export function heartbeatService(db: Db) { const resetTaskSession = shouldResetTaskSessionForWake(context); const sessionResetReason = describeSessionResetReason(context); const taskSessionForRun = resetTaskSession ? null : taskSession; - const previousSessionParams = normalizeSessionParams( - sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), + const explicitResumeSessionParams = normalizeSessionParams( + sessionCodec.deserialize(parseObject(context.resumeSessionParams)), ); + const explicitResumeSessionDisplayId = truncateDisplayId( + readNonEmptyString(context.resumeSessionDisplayId) ?? + (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(explicitResumeSessionParams) : null) ?? + readNonEmptyString(explicitResumeSessionParams?.sessionId), + ); + const previousSessionParams = + explicitResumeSessionParams ?? + (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ?? + normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)); const config = parseObject(agent.adapterConfig); const executionWorkspaceMode = resolveExecutionWorkspaceMode({ projectPolicy: projectExecutionWorkspacePolicy, @@ -2190,7 +2295,8 @@ export function heartbeatService(db: Db) { } const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId; let previousSessionDisplayId = truncateDisplayId( - taskSessionForRun?.sessionDisplayId ?? + explicitResumeSessionDisplayId ?? + taskSessionForRun?.sessionDisplayId ?? (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ?? readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback, @@ -2801,7 +2907,9 @@ export function heartbeatService(db: Db) { payload: promotedPayload, }); - const sessionBefore = await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey); + const sessionBefore = + readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ?? + await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey); const now = new Date(); const newRun = await tx .insert(heartbeatRuns) @@ -2880,10 +2988,30 @@ export function heartbeatService(db: Db) { triggerDetail, payload, }); - const issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; + let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; const agent = await getAgent(agentId); if (!agent) throw notFound("Agent not found"); + const explicitResumeSession = await resolveExplicitResumeSessionOverride(agent, payload, taskKey); + if (explicitResumeSession) { + enrichedContextSnapshot.resumeFromRunId = explicitResumeSession.resumeFromRunId; + enrichedContextSnapshot.resumeSessionDisplayId = explicitResumeSession.sessionDisplayId; + enrichedContextSnapshot.resumeSessionParams = explicitResumeSession.sessionParams; + if (!readNonEmptyString(enrichedContextSnapshot.issueId) && explicitResumeSession.issueId) { + enrichedContextSnapshot.issueId = explicitResumeSession.issueId; + } + if (!readNonEmptyString(enrichedContextSnapshot.taskId) && explicitResumeSession.taskId) { + enrichedContextSnapshot.taskId = explicitResumeSession.taskId; + } + if (!readNonEmptyString(enrichedContextSnapshot.taskKey) && explicitResumeSession.taskKey) { + enrichedContextSnapshot.taskKey = explicitResumeSession.taskKey; + } + issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueId; + } + const effectiveTaskKey = readNonEmptyString(enrichedContextSnapshot.taskKey) ?? taskKey; + const sessionBefore = + explicitResumeSession?.sessionDisplayId ?? + await resolveSessionBeforeForWakeup(agent, effectiveTaskKey); const writeSkippedRequest = async (skipReason: string) => { await db.insert(agentWakeupRequests).values({ @@ -2947,7 +3075,6 @@ export function heartbeatService(db: Db) { if (issueId && !bypassIssueExecutionLock) { const agentNameKey = normalizeAgentNameKey(agent.name); - const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); const outcome = await db.transaction(async (tx) => { await tx.execute( @@ -3298,8 +3425,6 @@ export function heartbeatService(db: Db) { .returning() .then((rows) => rows[0]); - const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); - const newRun = await db .insert(heartbeatRuns) .values({ From 75c7eb386875e8ae0cbe0fa17448c5ccc4a01114 Mon Sep 17 00:00:00 2001 From: dotta Date: Sun, 22 Mar 2026 06:16:48 -0500 Subject: [PATCH 05/15] Ignore test-only paths in dev restart tracking Co-Authored-By: Paperclip --- scripts/dev-runner-paths.mjs | 35 +++++++++++++++++++ scripts/dev-runner.mjs | 2 ++ server/src/__tests__/dev-runner-paths.test.ts | 20 +++++++++++ 3 files changed, 57 insertions(+) create mode 100644 scripts/dev-runner-paths.mjs create mode 100644 server/src/__tests__/dev-runner-paths.test.ts diff --git a/scripts/dev-runner-paths.mjs b/scripts/dev-runner-paths.mjs new file mode 100644 index 00000000..274be549 --- /dev/null +++ b/scripts/dev-runner-paths.mjs @@ -0,0 +1,35 @@ +const testDirectoryNames = new Set([ + "__tests__", + "_tests", + "test", + "tests", +]); + +const ignoredTestConfigBasenames = new Set([ + "jest.config.cjs", + "jest.config.js", + "jest.config.mjs", + "jest.config.ts", + "playwright.config.ts", + "vitest.config.ts", +]); + +export function shouldTrackDevServerPath(relativePath) { + const normalizedPath = String(relativePath).replaceAll("\\", "/").replace(/^\.\/+/, ""); + if (normalizedPath.length === 0) return false; + + const segments = normalizedPath.split("/"); + const basename = segments.at(-1) ?? normalizedPath; + + if (ignoredTestConfigBasenames.has(basename)) { + return false; + } + if (segments.some((segment) => testDirectoryNames.has(segment))) { + return false; + } + if (/\.(test|spec)\.[^/]+$/i.test(basename)) { + return false; + } + + return true; +} diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 7e13efef..091dbb19 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { fileURLToPath } from "node:url"; +import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; const mode = process.argv[2] === "watch" ? "watch" : "dev"; const cliArgs = process.argv.slice(3); @@ -164,6 +165,7 @@ function readSignature(absolutePath) { function addFileToSnapshot(snapshot, absolutePath) { const relativePath = toRelativePath(absolutePath); if (ignoredRelativePaths.has(relativePath)) return; + if (!shouldTrackDevServerPath(relativePath)) return; snapshot.set(relativePath, readSignature(absolutePath)); } diff --git a/server/src/__tests__/dev-runner-paths.test.ts b/server/src/__tests__/dev-runner-paths.test.ts new file mode 100644 index 00000000..76b35203 --- /dev/null +++ b/server/src/__tests__/dev-runner-paths.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs"; + +describe("shouldTrackDevServerPath", () => { + it("ignores common test file paths", () => { + expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/_tests/helpers.ts")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/tests/helpers.ts")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/test/helpers.ts")).toBe(false); + expect(shouldTrackDevServerPath("vitest.config.ts")).toBe(false); + }); + + it("keeps runtime paths restart-relevant", () => { + expect(shouldTrackDevServerPath("server/src/routes/health.ts")).toBe(true); + expect(shouldTrackDevServerPath("packages/shared/src/index.ts")).toBe(true); + expect(shouldTrackDevServerPath("server/src/testing/runtime.ts")).toBe(true); + }); +}); From a315838d4336b44eb0836e96dd072301344d89d8 Mon Sep 17 00:00:00 2001 From: dotta Date: Sun, 22 Mar 2026 07:05:08 -0500 Subject: [PATCH 06/15] fix: preserve agent instructions on adapter switch Co-Authored-By: Paperclip --- .../agent-instructions-routes.test.ts | 39 +++++++++++++++++++ server/src/routes/agents.ts | 38 +++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 4f6ca414..f47f8dcc 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -197,4 +197,43 @@ describe("agent instructions bundle routes", () => { expect.any(Object), ); }); + + it("preserves managed instructions config when switching adapters", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent(), + adapterType: "codex_local", + adapterConfig: { + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + model: "gpt-5.4", + }, + }); + + const res = await request(createApp()) + .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") + .send({ + adapterType: "claude_local", + adapterConfig: { + model: "claude-sonnet-4", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterType: "claude_local", + adapterConfig: expect.objectContaining({ + model: "claude-sonnet-4", + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + }), + }), + expect.any(Object), + ); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index af5a6574..6bacb46e 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -73,6 +73,13 @@ export function agentRoutes(db: Db) { }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); + const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ + "instructionsBundleMode", + "instructionsRootPath", + "instructionsEntryFile", + "instructionsFilePath", + "agentsMdPath", + ] as const; const router = Router(); const svc = agentService(db); @@ -303,6 +310,24 @@ export function agentRoutes(db: Db) { return trimmed.length > 0 ? trimmed : null; } + function preserveInstructionsBundleConfig( + existingAdapterConfig: Record, + nextAdapterConfig: Record, + ) { + const nextKeys = new Set(Object.keys(nextAdapterConfig)); + if (KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => nextKeys.has(key))) { + return nextAdapterConfig; + } + + const merged = { ...nextAdapterConfig }; + for (const key of KNOWN_INSTRUCTIONS_BUNDLE_KEYS) { + if (merged[key] === undefined && existingAdapterConfig[key] !== undefined) { + merged[key] = existingAdapterConfig[key]; + } + } + return merged; + } + function parseBooleanLike(value: unknown): boolean | null { if (typeof value === "boolean") return value; if (typeof value === "number") { @@ -1710,9 +1735,18 @@ export function agentRoutes(db: Db) { Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); if (touchesAdapterConfiguration) { - const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; + const changingAdapterType = + typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType; + let rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) - : (asRecord(existing.adapterConfig) ?? {}); + : existingAdapterConfig; + if (changingAdapterType) { + rawEffectiveAdapterConfig = preserveInstructionsBundleConfig( + existingAdapterConfig, + rawEffectiveAdapterConfig, + ); + } const effectiveAdapterConfig = applyCreateDefaultsByAdapterType( requestedAdapterType, rawEffectiveAdapterConfig, From 1adfd30b3be2dd3d8fa0aa54d7b2ad554d067f27 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 07:21:34 -0500 Subject: [PATCH 07/15] fix: recover managed agent instructions from disk Co-Authored-By: Paperclip --- .../agent-instructions-service.test.ts | 31 +++++++++++++ server/src/services/agent-instructions.ts | 44 ++++++++++++++++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index 0e0d9d39..a0731ee6 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -161,4 +161,35 @@ describe("agent instructions service", () => { "docs/TOOLS.md", ]); }); + + it("recovers a managed bundle from disk when bundle config metadata is missing", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-recover-"); + cleanupDirs.add(paperclipHome); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Recovered Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({}); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.mode).toBe("managed"); + expect(bundle.rootPath).toBe(managedRoot); + expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" }); + }); }); diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index d3fc7008..1e1a5b32 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -272,6 +272,24 @@ function deriveBundleState(agent: AgentLike): BundleState { }; } +async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise { + if (state.rootPath) return state; + + const managedRootPath = resolveManagedInstructionsRoot(agent); + const stat = await statIfExists(managedRootPath); + if (!stat?.isDirectory()) return state; + + const files = await listFilesRecursive(managedRootPath); + if (files.length === 0) return state; + + return { + ...state, + mode: "managed", + rootPath: managedRootPath, + resolvedEntryPath: path.resolve(managedRootPath, state.entryFile), + }; +} + function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle { const nextFiles = [...files]; if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) { @@ -366,7 +384,7 @@ export function syncInstructionsBundleConfigFromFilePath( export function agentInstructionsService() { async function getBundle(agent: AgentLike): Promise { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); if (!state.rootPath) return toBundle(agent, state, []); const stat = await statIfExists(state.rootPath); if (!stat?.isDirectory()) { @@ -381,7 +399,7 @@ export function agentInstructionsService() { } async function readFile(agent: AgentLike, relativePath: string): Promise { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { const content = asString(state.config[PROMPT_KEY]); if (content === null) throw notFound("Instructions file not found"); @@ -422,9 +440,21 @@ export function agentInstructionsService() { agent: AgentLike, options?: { clearLegacyPromptTemplate?: boolean }, ): Promise<{ adapterConfig: Record; state: BundleState }> { - const current = deriveBundleState(agent); + const derived = deriveBundleState(agent); + const current = await recoverManagedBundleState(agent, derived); if (current.rootPath && current.mode) { - return { adapterConfig: current.config, state: current }; + const adapterConfig = derived.rootPath + ? current.config + : applyBundleConfig(current.config, { + mode: current.mode, + rootPath: current.rootPath, + entryFile: current.entryFile, + clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, + }); + return { + adapterConfig, + state: deriveBundleState({ ...agent, adapterConfig }), + }; } const managedRoot = resolveManagedInstructionsRoot(agent); @@ -462,7 +492,7 @@ export function agentInstructionsService() { clearLegacyPromptTemplate?: boolean; }, ): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record }> { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); const nextMode = input.mode ?? state.mode ?? "managed"; const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile; let nextRootPath: string; @@ -544,7 +574,7 @@ export function agentInstructionsService() { bundle: AgentInstructionsBundle; adapterConfig: Record; }> { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file"); } @@ -564,7 +594,7 @@ export function agentInstructionsService() { entryFile: string; warnings: string[]; }> { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); if (state.rootPath) { const stat = await statIfExists(state.rootPath); if (stat?.isDirectory()) { From 3b2cb3a699397eaf5207e5b9d27b030d57b8b3bb Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 15:57:57 -0500 Subject: [PATCH 08/15] Show all companies' agents on instance heartbeats page The /instance/scheduler-heartbeats endpoint was filtering agents by the requesting user's company memberships, which meant non-member companies (like donchitos) were hidden. Since this is an instance-level admin page, it should show all agents across all companies. - Added assertInstanceAdmin to authz.ts for reuse - Replaced assertBoard + company filter with assertInstanceAdmin - Removed the companyIds-based WHERE clause since instance admins see all Co-Authored-By: Paperclip --- server/src/routes/agents.ts | 15 ++------------- server/src/routes/authz.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 6bacb46e..5621d259 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -43,7 +43,7 @@ import { workspaceOperationService, } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; @@ -855,17 +855,7 @@ export function agentRoutes(db: Db) { }); router.get("/instance/scheduler-heartbeats", async (req, res) => { - assertBoard(req); - - const accessConditions = []; - if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { - const allowedCompanyIds = req.actor.companyIds ?? []; - if (allowedCompanyIds.length === 0) { - res.json([]); - return; - } - accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds)); - } + assertInstanceAdmin(req); const rows = await db .select({ @@ -883,7 +873,6 @@ export function agentRoutes(db: Db) { }) .from(agentsTable) .innerJoin(companies, eq(agentsTable.companyId, companies.id)) - .where(accessConditions.length > 0 ? and(...accessConditions) : undefined) .orderBy(companies.name, agentsTable.name); const items: InstanceSchedulerHeartbeatAgent[] = rows diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 4782489b..a881d4ff 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -7,6 +7,14 @@ export function assertBoard(req: Request) { } } +export function assertInstanceAdmin(req: Request) { + assertBoard(req); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { + return; + } + throw forbidden("Instance admin access required"); +} + export function assertCompanyAccess(req: Request, companyId: string) { if (req.actor.type === "none") { throw unauthorized(); From 0bb1ee3caa07e5eec4a05bcd7fcbb4b02075221e Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:05:39 -0500 Subject: [PATCH 09/15] Recover agent instructions from disk Co-Authored-By: Paperclip --- .../agent-instructions-routes.test.ts | 38 +++++++++ .../agent-instructions-service.test.ts | 84 +++++++++++++++++++ server/src/routes/agents.ts | 8 +- server/src/services/agent-instructions.ts | 44 +++++++++- 4 files changed, 169 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index f47f8dcc..99d6061d 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -236,4 +236,42 @@ describe("agent instructions bundle routes", () => { expect.any(Object), ); }); + + it("merges same-adapter config patches so instructions metadata is not dropped", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent(), + adapterType: "codex_local", + adapterConfig: { + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + model: "gpt-5.4", + }, + }); + + const res = await request(createApp()) + .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") + .send({ + adapterConfig: { + command: "codex --profile engineer", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + command: "codex --profile engineer", + model: "gpt-5.4", + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + }), + }), + expect.any(Object), + ); + }); }); diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index a0731ee6..cdd98aa2 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -192,4 +192,88 @@ describe("agent instructions service", () => { expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" }); }); + + it("prefers the managed bundle on disk when managed metadata points at a stale root", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-stale-managed-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-stale-root-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "managed", + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"), + }); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.mode).toBe("managed"); + expect(bundle.rootPath).toBe(managedRoot); + expect(bundle.entryFile).toBe("AGENTS.md"); + expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + expect(bundle.warnings).toEqual([ + `Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`, + "Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.", + ]); + expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" }); + }); + + it("recovers the managed bundle when stale root metadata is present but mode is missing", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-partial-managed-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-partial-root-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + }); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.mode).toBe("managed"); + expect(bundle.rootPath).toBe(managedRoot); + expect(bundle.entryFile).toBe("AGENTS.md"); + expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + expect(bundle.warnings).toEqual([ + `Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`, + "Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.", + ]); + expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" }); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5621d259..2769a1d1 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1727,9 +1727,13 @@ export function agentRoutes(db: Db) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const changingAdapterType = typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType; - let rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) - : existingAdapterConfig; + : null; + let rawEffectiveAdapterConfig = requestedAdapterConfig ?? existingAdapterConfig; + if (requestedAdapterConfig && !changingAdapterType) { + rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig }; + } if (changingAdapterType) { rawEffectiveAdapterConfig = preserveInstructionsBundleConfig( existingAdapterConfig, diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index 1e1a5b32..9b4238d4 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -273,8 +273,6 @@ function deriveBundleState(agent: AgentLike): BundleState { } async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise { - if (state.rootPath) return state; - const managedRootPath = resolveManagedInstructionsRoot(agent); const stat = await statIfExists(managedRootPath); if (!stat?.isDirectory()) return state; @@ -282,11 +280,51 @@ async function recoverManagedBundleState(agent: AgentLike, state: BundleState): const files = await listFilesRecursive(managedRootPath); if (files.length === 0) return state; + const recoveredEntryFile = files.includes(state.entryFile) + ? state.entryFile + : files.includes(ENTRY_FILE_DEFAULT) + ? ENTRY_FILE_DEFAULT + : files[0]!; + + if (!state.rootPath) { + return { + ...state, + mode: "managed", + rootPath: managedRootPath, + entryFile: recoveredEntryFile, + resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile), + }; + } + + if (state.mode === "external") return state; + + const resolvedConfiguredRoot = path.resolve(state.rootPath); + const configuredRootMatchesManaged = resolvedConfiguredRoot === managedRootPath; + const hasEntryMismatch = recoveredEntryFile !== state.entryFile; + + if (configuredRootMatchesManaged && !hasEntryMismatch) { + return state; + } + + const warnings = [...state.warnings]; + if (!configuredRootMatchesManaged) { + warnings.push( + `Recovered managed instructions from disk at ${managedRootPath}; ignoring stale configured root ${state.rootPath}.`, + ); + } + if (hasEntryMismatch) { + warnings.push( + `Recovered managed instructions entry file from disk as ${recoveredEntryFile}; previous entry ${state.entryFile} was missing.`, + ); + } + return { ...state, mode: "managed", rootPath: managedRootPath, - resolvedEntryPath: path.resolve(managedRootPath, state.entryFile), + entryFile: recoveredEntryFile, + resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile), + warnings, }; } From 43b21c6033e5fea18bcf12ff3eeec4fb34c1caa6 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:09:44 -0500 Subject: [PATCH 10/15] Ignore .paperclip paths in restart tracking Co-Authored-By: Paperclip --- scripts/dev-runner-paths.mjs | 3 +++ server/src/__tests__/dev-runner-paths.test.ts | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/dev-runner-paths.mjs b/scripts/dev-runner-paths.mjs index 274be549..efea8f51 100644 --- a/scripts/dev-runner-paths.mjs +++ b/scripts/dev-runner-paths.mjs @@ -21,6 +21,9 @@ export function shouldTrackDevServerPath(relativePath) { const segments = normalizedPath.split("/"); const basename = segments.at(-1) ?? normalizedPath; + if (segments.includes(".paperclip")) { + return false; + } if (ignoredTestConfigBasenames.has(basename)) { return false; } diff --git a/server/src/__tests__/dev-runner-paths.test.ts b/server/src/__tests__/dev-runner-paths.test.ts index 76b35203..6f9a5b80 100644 --- a/server/src/__tests__/dev-runner-paths.test.ts +++ b/server/src/__tests__/dev-runner-paths.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs"; describe("shouldTrackDevServerPath", () => { - it("ignores common test file paths", () => { + it("ignores repo-local Paperclip state and common test file paths", () => { + expect( + shouldTrackDevServerPath( + ".paperclip/worktrees/PAP-712-for-project-configuration-get-rid-of-the-overview-tab-for-now/.agents/skills/paperclip", + ), + ).toBe(false); expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false); expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false); expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false); From 2daae758b135ce49b328b1cb61a51a380f2822f0 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:17:15 -0500 Subject: [PATCH 11/15] Include all agents on heartbeats page regardless of interval config Agents without a heartbeat interval configured (intervalSec=0) were filtered out, making them invisible on the instance heartbeats page. This prevented managing heartbeats for agents that hadn't been configured yet (e.g. donchitos company agents). Co-Authored-By: Paperclip --- server/src/routes/agents.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 2769a1d1..801ee91b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -901,7 +901,6 @@ export function agentRoutes(db: Db) { }; }) .filter((item) => - item.intervalSec > 0 && item.status !== "paused" && item.status !== "terminated" && item.status !== "pending_approval", From c0c1fd17cb9884d589d6e15e321edf20b5af5916 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:22:22 -0500 Subject: [PATCH 12/15] Add "Disable All" button to heartbeats settings page Adds a destructive-variant button at the top of the heartbeats page that disables timer heartbeats for all agents at once. The button only appears when at least one agent has heartbeats enabled, and shows a loading state while processing. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/InstanceSettings.tsx | 42 ++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx index a4781e1f..1a48bf0a 100644 --- a/ui/src/pages/InstanceSettings.tsx +++ b/ui/src/pages/InstanceSettings.tsx @@ -77,9 +77,38 @@ export function InstanceSettings() { }, }); + const disableAllMutation = useMutation({ + mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => { + const enabled = agentRows.filter((a) => a.heartbeatEnabled); + for (const agentRow of enabled) { + const agent = await agentsApi.get(agentRow.id, agentRow.companyId); + const runtimeConfig = asRecord(agent.runtimeConfig) ?? {}; + const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {}; + await agentsApi.update( + agentRow.id, + { + runtimeConfig: { + ...runtimeConfig, + heartbeat: { ...heartbeat, enabled: false }, + }, + }, + agentRow.companyId, + ); + } + }, + onSuccess: async () => { + setActionError(null); + await queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }); + }, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to disable all heartbeats."); + }, + }); + const agents = heartbeatsQuery.data ?? []; const activeCount = agents.filter((agent) => agent.schedulerActive).length; const disabledCount = agents.length - activeCount; + const anyEnabled = agents.some((a) => a.heartbeatEnabled); const grouped = useMemo(() => { const map = new Map(); @@ -120,10 +149,21 @@ export function InstanceSettings() {

-
+
{activeCount} active {disabledCount} disabled {grouped.length} {grouped.length === 1 ? "company" : "companies"} + {anyEnabled && ( + + )}
{actionError && ( From 19154d0fec6ae6e8d33b9fb71285acec191ddcd6 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:55:10 -0500 Subject: [PATCH 13/15] Clarify Codex instruction sources Co-Authored-By: Paperclip --- docs/adapters/codex-local.md | 6 ++ packages/adapters/codex-local/src/index.ts | 2 + .../codex-local/src/server/execute.ts | 8 ++- .../src/__tests__/codex-local-execute.test.ts | 56 +++++++++++++++++++ skills/paperclip/SKILL.md | 8 +-- ui/src/adapters/codex-local/config-fields.tsx | 2 +- 6 files changed, 76 insertions(+), 6 deletions(-) diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index ad187f75..ff30263b 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -40,6 +40,12 @@ pnpm paperclipai agent local-cli codexcoder --company-id This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent. +## Instructions Resolution + +If `instructionsFilePath` is configured, Paperclip reads that file and prepends it to the stdin prompt sent to `codex exec` on every run. + +This is separate from any workspace-level instruction discovery that Codex itself performs in the run `cwd`. Paperclip does not disable Codex-native repo instruction files, so a repo-local `AGENTS.md` may still be loaded by Codex in addition to the Paperclip-managed agent instructions. + ## Environment Test The environment test checks: diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 0d881a2b..58511eb6 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -40,6 +40,8 @@ Operational fields: Notes: - Prompts are piped via stdin (Codex receives "-" prompt argument). +- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run. +- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath. - Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home. - Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex). - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index b6bda8df..eaf3d1b5 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -427,16 +427,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - if (!instructionsFilePath) return [] as string[]; + if (!instructionsFilePath) { + return [repoAgentsNote]; + } if (instructionsPrefix.length > 0) { return [ `Loaded agent instructions from ${instructionsFilePath}`, `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, + repoAgentsNote, ]; } return [ `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + repoAgentsNote, ]; })(); const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 3f1f15df..9d386397 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -139,6 +139,62 @@ describe("codex execute", () => { } }); + it("emits a command note that Codex auto-applies repo-scoped AGENTS.md files", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-notes-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + let commandNotes: string[] = []; + try { + const result = await execute({ + runId: "run-notes", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + commandNotes = Array.isArray(meta.commandNotes) ? meta.commandNotes : []; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect(commandNotes).toContain( + "Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.", + ); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index ee5ac2ae..407f08da 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -330,7 +330,7 @@ Use this when validating Paperclip itself (assignment flow, checkouts, run visib 1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`): ```bash -pnpm paperclipai issue create \ +npx paperclipai issue create \ --company-id "$PAPERCLIP_COMPANY_ID" \ --title "Self-test: assignment/watch flow" \ --description "Temporary validation issue" \ @@ -341,19 +341,19 @@ pnpm paperclipai issue create \ 2. Trigger and watch a heartbeat for that assignee: ```bash -pnpm paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID" +npx paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID" ``` 3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted: ```bash -pnpm paperclipai issue get +npx paperclipai issue get ``` 4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior: ```bash -pnpm paperclipai issue update --assignee-agent-id --status todo +npx paperclipai issue update --assignee-agent-id --status todo ``` 5. Cleanup: mark temporary issues done/cancelled with a clear note. diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 125630ba..86bef600 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -11,7 +11,7 @@ import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields"; const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; const instructionsFileHint = - "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace."; export function CodexLocalConfigFields({ mode, From 55b26ed59067d1f1b959e47387fb493703032e94 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 17:18:17 -0500 Subject: [PATCH 14/15] Address Greptile review on agent runtime PR Co-Authored-By: Paperclip --- ui/src/pages/InstanceSettings.tsx | 64 +++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx index 1a48bf0a..18faff5e 100644 --- a/ui/src/pages/InstanceSettings.tsx +++ b/ui/src/pages/InstanceSettings.tsx @@ -80,25 +80,50 @@ export function InstanceSettings() { const disableAllMutation = useMutation({ mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => { const enabled = agentRows.filter((a) => a.heartbeatEnabled); - for (const agentRow of enabled) { - const agent = await agentsApi.get(agentRow.id, agentRow.companyId); - const runtimeConfig = asRecord(agent.runtimeConfig) ?? {}; - const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {}; - await agentsApi.update( - agentRow.id, - { - runtimeConfig: { - ...runtimeConfig, - heartbeat: { ...heartbeat, enabled: false }, + if (enabled.length === 0) return enabled; + + const results = await Promise.allSettled( + enabled.map(async (agentRow) => { + const agent = await agentsApi.get(agentRow.id, agentRow.companyId); + const runtimeConfig = asRecord(agent.runtimeConfig) ?? {}; + const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {}; + await agentsApi.update( + agentRow.id, + { + runtimeConfig: { + ...runtimeConfig, + heartbeat: { ...heartbeat, enabled: false }, + }, }, - }, - agentRow.companyId, + agentRow.companyId, + ); + }), + ); + + const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failures.length > 0) { + const firstError = failures[0]?.reason; + const detail = firstError instanceof Error ? firstError.message : "Unknown error"; + throw new Error( + failures.length === 1 + ? `Failed to disable 1 timer heartbeat: ${detail}` + : `Failed to disable ${failures.length} of ${enabled.length} timer heartbeats. First error: ${detail}`, ); } + return enabled; }, - onSuccess: async () => { + onSuccess: async (updatedRows) => { setActionError(null); - await queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }); + const companies = new Set(updatedRows.map((row) => row.companyId)); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }), + ...Array.from(companies, (companyId) => + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }), + ), + ...updatedRows.map((row) => + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(row.id) }), + ), + ]); }, onError: (error) => { setActionError(error instanceof Error ? error.message : "Failed to disable all heartbeats."); @@ -108,7 +133,8 @@ export function InstanceSettings() { const agents = heartbeatsQuery.data ?? []; const activeCount = agents.filter((agent) => agent.schedulerActive).length; const disabledCount = agents.length - activeCount; - const anyEnabled = agents.some((a) => a.heartbeatEnabled); + const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length; + const anyEnabled = enabledCount > 0; const grouped = useMemo(() => { const map = new Map(); @@ -159,7 +185,13 @@ export function InstanceSettings() { size="sm" className="ml-auto h-7 text-xs" disabled={disableAllMutation.isPending} - onClick={() => disableAllMutation.mutate(agents)} + onClick={() => { + const noun = enabledCount === 1 ? "agent" : "agents"; + if (!window.confirm(`Disable timer heartbeats for all ${enabledCount} enabled ${noun}?`)) { + return; + } + disableAllMutation.mutate(agents); + }} > {disableAllMutation.isPending ? "Disabling..." : "Disable All"} From c8f8f6752fb14a1a7a5d2658fb27a22588b1db9c Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 19:43:50 -0500 Subject: [PATCH 15/15] fix: address latest Greptile runtime review --- packages/shared/src/validators/agent.ts | 1 + .../agent-instructions-routes.test.ts | 41 ++++++++++ .../agent-instructions-service.test.ts | 82 +++++++++++++++++++ .../execution-workspace-policy.test.ts | 2 + server/src/routes/agents.ts | 13 ++- server/src/services/agent-instructions.ts | 47 ++++++++--- .../services/execution-workspace-policy.ts | 3 + 7 files changed, 177 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index d72a76a2..8c29150b 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -73,6 +73,7 @@ export const updateAgentSchema = createAgentSchema .partial() .extend({ permissions: z.never().optional(), + replaceAdapterConfig: z.boolean().optional(), status: z.enum(AGENT_STATUSES).optional(), spentMonthlyCents: z.number().int().nonnegative().optional(), }); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 99d6061d..16b16ca3 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -274,4 +274,45 @@ describe("agent instructions bundle routes", () => { expect.any(Object), ); }); + + it("replaces adapter config when replaceAdapterConfig is true", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent(), + adapterType: "codex_local", + adapterConfig: { + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + model: "gpt-5.4", + }, + }); + + const res = await request(createApp()) + .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") + .send({ + replaceAdapterConfig: true, + adapterConfig: { + command: "codex --profile engineer", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + command: "codex --profile engineer", + }), + }), + expect.any(Object), + ); + expect(res.body.adapterConfig).toMatchObject({ + command: "codex --profile engineer", + }); + expect(res.body.adapterConfig.instructionsBundleMode).toBeUndefined(); + expect(res.body.adapterConfig.instructionsRootPath).toBeUndefined(); + expect(res.body.adapterConfig.instructionsEntryFile).toBeUndefined(); + expect(res.body.adapterConfig.instructionsFilePath).toBeUndefined(); + }); }); diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index cdd98aa2..67eea3ca 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -236,6 +236,88 @@ describe("agent instructions service", () => { expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" }); }); + it("heals stale managed metadata when writing bundle files", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-write-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-write-stale-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "managed", + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"), + }); + + const result = await svc.writeFile(agent, "docs/TOOLS.md", "## Tools\n"); + + expect(result.adapterConfig).toMatchObject({ + instructionsBundleMode: "managed", + instructionsRootPath: managedRoot, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: path.join(managedRoot, "AGENTS.md"), + }); + await expect(fs.readFile(path.join(managedRoot, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n"); + }); + + it("heals stale managed metadata when deleting bundle files", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-delete-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-delete-stale-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + await fs.writeFile(path.join(managedRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "managed", + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"), + }); + + const result = await svc.deleteFile(agent, "docs/TOOLS.md"); + + expect(result.adapterConfig).toMatchObject({ + instructionsBundleMode: "managed", + instructionsRootPath: managedRoot, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: path.join(managedRoot, "AGENTS.md"), + }); + await expect(fs.stat(path.join(managedRoot, "docs", "TOOLS.md"))).rejects.toThrow(); + expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + }); + it("recovers the managed bundle when stale root metadata is present but mode is missing", async () => { const paperclipHome = await makeTempDir("paperclip-agent-instructions-partial-managed-"); const staleRoot = await makeTempDir("paperclip-agent-instructions-partial-root-"); diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index 71ef6192..ecb5f76e 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -149,6 +149,8 @@ describe("execution workspace policy helpers", () => { expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace"); expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default"); expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace(null)).toBe("agent_default"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace(undefined)).toBe("agent_default"); }); it("disables project execution workspace policy when the instance flag is off", () => { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 801ee91b..f642eb10 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1702,6 +1702,8 @@ export function agentRoutes(db: Db) { } const patchData = { ...(req.body as Record) }; + const replaceAdapterConfig = patchData.replaceAdapterConfig === true; + delete patchData.replaceAdapterConfig; if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) { const adapterConfig = asRecord(patchData.adapterConfig); if (!adapterConfig) { @@ -1729,8 +1731,17 @@ export function agentRoutes(db: Db) { const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) : null; + if ( + requestedAdapterConfig + && replaceAdapterConfig + && KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => + existingAdapterConfig[key] !== undefined && requestedAdapterConfig[key] === undefined, + ) + ) { + await assertCanManageInstructionsPath(req, existing); + } let rawEffectiveAdapterConfig = requestedAdapterConfig ?? existingAdapterConfig; - if (requestedAdapterConfig && !changingAdapterType) { + if (requestedAdapterConfig && !changingAdapterType && !replaceAdapterConfig) { rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig }; } if (changingAdapterType) { diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index 9b4238d4..231ed839 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -383,6 +383,36 @@ function applyBundleConfig( return next; } +function buildPersistedBundleConfig( + derived: BundleState, + current: BundleState, + options?: { clearLegacyPromptTemplate?: boolean }, +): Record { + const currentRootPath = current.rootPath ? path.resolve(current.rootPath) : null; + const derivedRootPath = derived.rootPath ? path.resolve(derived.rootPath) : null; + const configMatchesRecoveredState = + derived.mode === current.mode + && derivedRootPath !== null + && currentRootPath !== null + && derivedRootPath === currentRootPath + && derived.entryFile === current.entryFile; + + if (configMatchesRecoveredState && !options?.clearLegacyPromptTemplate) { + return current.config; + } + + if (!current.rootPath || !current.mode) { + return current.config; + } + + return applyBundleConfig(current.config, { + mode: current.mode, + rootPath: current.rootPath, + entryFile: current.entryFile, + clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, + }); +} + async function writeBundleFiles( rootPath: string, files: Record, @@ -481,14 +511,7 @@ export function agentInstructionsService() { const derived = deriveBundleState(agent); const current = await recoverManagedBundleState(agent, derived); if (current.rootPath && current.mode) { - const adapterConfig = derived.rootPath - ? current.config - : applyBundleConfig(current.config, { - mode: current.mode, - rootPath: current.rootPath, - entryFile: current.entryFile, - clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, - }); + const adapterConfig = buildPersistedBundleConfig(derived, current, options); return { adapterConfig, state: deriveBundleState({ ...agent, adapterConfig }), @@ -612,7 +635,8 @@ export function agentInstructionsService() { bundle: AgentInstructionsBundle; adapterConfig: Record; }> { - const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); + const derived = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, derived); if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file"); } @@ -623,8 +647,9 @@ export function agentInstructionsService() { } const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath); await fs.rm(absolutePath, { force: true }); - const bundle = await getBundle(agent); - return { bundle, adapterConfig: state.config }; + const adapterConfig = buildPersistedBundleConfig(derived, state); + const bundle = await getBundle({ ...agent, adapterConfig }); + return { bundle, adapterConfig }; } async function exportFiles(agent: AgentLike): Promise<{ diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 4f79beb3..bb5ef76d 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -135,6 +135,9 @@ export function defaultIssueExecutionWorkspaceSettingsForProject( export function issueExecutionWorkspaceModeForPersistedWorkspace( mode: string | null | undefined, ): IssueExecutionWorkspaceSettings["mode"] { + if (mode === null || mode === undefined) { + return "agent_default"; + } if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") { return mode; }