Merge pull request #1654 from paperclipai/pr/pap-795-agent-runtime

fix(runtime): improve agent recovery and heartbeat operations
This commit is contained in:
Dotta 2026-03-23 19:44:51 -05:00 committed by GitHub
commit f2637e6972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1291 additions and 64 deletions

View file

@ -40,6 +40,12 @@ pnpm paperclipai agent local-cli codexcoder --company-id <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:

View file

@ -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).

View file

@ -427,16 +427,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
}
const repoAgentsNote =
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.";
const commandNotes = (() => {
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, "");

View file

@ -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(),
});

View file

@ -0,0 +1,38 @@
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 (segments.includes(".paperclip")) {
return false;
}
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;
}

View file

@ -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);
@ -16,7 +17,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",
@ -165,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));
}

View file

@ -197,4 +197,122 @@ 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),
);
});
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),
);
});
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();
});
});

View file

@ -161,4 +161,201 @@ 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" });
});
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("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-");
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" });
});
});

View file

@ -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");

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs";
describe("shouldTrackDevServerPath", () => {
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);
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);
});
});

View file

@ -3,6 +3,7 @@ import {
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
@ -142,6 +143,16 @@ 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");
expect(issueExecutionWorkspaceModeForPersistedWorkspace(null)).toBe("agent_default");
expect(issueExecutionWorkspaceModeForPersistedWorkspace(undefined)).toBe("agent_default");
});
it("disables project execution workspace policy when the instance flag is off", () => {
expect(
gateProjectExecutionWorkspacePolicy(

View file

@ -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({

View file

@ -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<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
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<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
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<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
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]);
});
});

View file

@ -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";
@ -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<string, unknown>,
nextAdapterConfig: Record<string, unknown>,
) {
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") {
@ -830,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({
@ -858,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
@ -887,7 +901,6 @@ export function agentRoutes(db: Db) {
};
})
.filter((item) =>
item.intervalSec > 0 &&
item.status !== "paused" &&
item.status !== "terminated" &&
item.status !== "pending_approval",
@ -1689,6 +1702,8 @@ export function agentRoutes(db: Db) {
}
const patchData = { ...(req.body as Record<string, unknown>) };
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
delete patchData.replaceAdapterConfig;
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
const adapterConfig = asRecord(patchData.adapterConfig);
if (!adapterConfig) {
@ -1710,9 +1725,31 @@ 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;
const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.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 && !replaceAdapterConfig) {
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig };
}
if (changingAdapterType) {
rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
existingAdapterConfig,
rawEffectiveAdapterConfig,
);
}
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
requestedAdapterType,
rawEffectiveAdapterConfig,

View file

@ -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();

View file

@ -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,

View file

@ -272,6 +272,62 @@ function deriveBundleState(agent: AgentLike): BundleState {
};
}
async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> {
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;
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,
entryFile: recoveredEntryFile,
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
warnings,
};
}
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
const nextFiles = [...files];
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
@ -327,6 +383,36 @@ function applyBundleConfig(
return next;
}
function buildPersistedBundleConfig(
derived: BundleState,
current: BundleState,
options?: { clearLegacyPromptTemplate?: boolean },
): Record<string, unknown> {
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<string, string>,
@ -366,7 +452,7 @@ export function syncInstructionsBundleConfigFromFilePath(
export function agentInstructionsService() {
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
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 +467,7 @@ export function agentInstructionsService() {
}
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
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 +508,14 @@ export function agentInstructionsService() {
agent: AgentLike,
options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{ adapterConfig: Record<string, unknown>; 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 = buildPersistedBundleConfig(derived, current, options);
return {
adapterConfig,
state: deriveBundleState({ ...agent, adapterConfig }),
};
}
const managedRoot = resolveManagedInstructionsRoot(agent);
@ -462,7 +553,7 @@ export function agentInstructionsService() {
clearLegacyPromptTemplate?: boolean;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
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 +635,8 @@ export function agentInstructionsService() {
bundle: AgentInstructionsBundle;
adapterConfig: Record<string, unknown>;
}> {
const state = 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");
}
@ -555,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<{
@ -564,7 +657,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()) {

View file

@ -132,6 +132,21 @@ 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;
}
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
return "agent_default";
}
return "shared_workspace";
}
export function resolveExecutionWorkspaceMode(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;

View file

@ -45,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js";
import {
buildExecutionWorkspaceAdapterConfig,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
@ -325,6 +326,51 @@ async function resolveLedgerScopeForRun(
};
}
type ResumeSessionRow = {
sessionParamsJson: Record<string, unknown> | 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 {
@ -977,6 +1023,57 @@ export function heartbeatService(db: Db) {
return runtimeForRun?.sessionId ?? null;
}
async function resolveExplicitResumeSessionOverride(
agent: typeof agents.$inferSelect,
payload: Record<string, unknown> | 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<string, unknown>,
@ -1920,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,
@ -2098,11 +2204,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<string, unknown> = {};
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;
@ -2171,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,
@ -2782,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)
@ -2861,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({
@ -2928,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(
@ -3279,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({

View file

@ -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<boolean>`
(
${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<Date | null>`
(
@ -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));
}

View file

@ -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 <issue-id-or-identifier>
npx paperclipai issue get <issue-id-or-identifier>
```
4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior:
```bash
pnpm paperclipai issue update <issue-id> --assignee-agent-id <other-agent-id> --status todo
npx paperclipai issue update <issue-id> --assignee-agent-id <other-agent-id> --status todo
```
5. Cleanup: mark temporary issues done/cancelled with a clear note.

View file

@ -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,

View file

@ -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);

View file

@ -54,6 +54,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<string, unknown>) => void;
@ -269,10 +280,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,
@ -299,9 +306,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;
@ -681,7 +696,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
>
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
? "Existing isolated workspace"
: option.label}
</option>
))}
</select>

View file

@ -167,6 +167,9 @@ interface IssuesListProps {
issueLinkState?: unknown;
initialAssignees?: string[];
initialSearch?: string;
searchFilters?: {
participantAgentId?: string;
};
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
@ -183,6 +186,7 @@ export function IssuesList({
issueLinkState,
initialAssignees,
initialSearch,
searchFilters,
onSearchChange,
onUpdateIssue,
}: IssuesListProps) {
@ -240,8 +244,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,
});

View file

@ -573,9 +573,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({
@ -593,7 +593,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");
@ -1175,12 +1174,15 @@ function AgentOverview({
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent Issues</h3>
<Link to={`/issues?assignee=${agentId}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
<Link
to={`/issues?participantAgentId=${agentId}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
See All &rarr;
</Link>
</div>
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No assigned issues.</p>
<p className="text-sm text-muted-foreground">No recent issues.</p>
) : (
<div className="border border-border rounded-lg">
{assignedIssues.slice(0, 10).map((issue) => (

View file

@ -77,9 +77,64 @@ export function InstanceSettings() {
},
});
const disableAllMutation = useMutation({
mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => {
const enabled = agentRows.filter((a) => a.heartbeatEnabled);
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,
);
}),
);
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 (updatedRows) => {
setActionError(null);
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.");
},
});
const agents = heartbeatsQuery.data ?? [];
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
const disabledCount = agents.length - activeCount;
const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length;
const anyEnabled = enabledCount > 0;
const grouped = useMemo(() => {
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
@ -120,10 +175,27 @@ export function InstanceSettings() {
</p>
</div>
<div className="flex gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? "company" : "companies"}</span>
{anyEnabled && (
<Button
variant="destructive"
size="sm"
className="ml-auto h-7 text-xs"
disabled={disableAllMutation.isPending}
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"}
</Button>
)}
</div>
{actionError && (

View file

@ -21,6 +21,7 @@ export function Issues() {
const queryClient = useQueryClient();
const initialSearch = searchParams.get("q") ?? "";
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const debounceRef = useRef<ReturnType<typeof setTimeout>>(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}
/>
);
}