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. 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 ## Environment Test
The environment test checks: The environment test checks:

View file

@ -40,6 +40,8 @@ Operational fields:
Notes: Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument). - 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. - 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). - 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). - 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 = (() => { const commandNotes = (() => {
if (!instructionsFilePath) return [] as string[]; if (!instructionsFilePath) {
return [repoAgentsNote];
}
if (instructionsPrefix.length > 0) { if (instructionsPrefix.length > 0) {
return [ return [
`Loaded agent instructions from ${instructionsFilePath}`, `Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
repoAgentsNote,
]; ];
} }
return [ return [
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
repoAgentsNote,
]; ];
})(); })();
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");

View file

@ -73,6 +73,7 @@ export const updateAgentSchema = createAgentSchema
.partial() .partial()
.extend({ .extend({
permissions: z.never().optional(), permissions: z.never().optional(),
replaceAdapterConfig: z.boolean().optional(),
status: z.enum(AGENT_STATUSES).optional(), status: z.enum(AGENT_STATUSES).optional(),
spentMonthlyCents: z.number().int().nonnegative().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 { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process"; import { stdin, stdout } from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
const mode = process.argv[2] === "watch" ? "watch" : "dev"; const mode = process.argv[2] === "watch" ? "watch" : "dev";
const cliArgs = process.argv.slice(3); 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 devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
const watchedDirectories = [ const watchedDirectories = [
".paperclip",
"cli", "cli",
"scripts", "scripts",
"server", "server",
@ -165,6 +165,7 @@ function readSignature(absolutePath) {
function addFileToSnapshot(snapshot, absolutePath) { function addFileToSnapshot(snapshot, absolutePath) {
const relativePath = toRelativePath(absolutePath); const relativePath = toRelativePath(absolutePath);
if (ignoredRelativePaths.has(relativePath)) return; if (ignoredRelativePaths.has(relativePath)) return;
if (!shouldTrackDevServerPath(relativePath)) return;
snapshot.set(relativePath, readSignature(absolutePath)); snapshot.set(relativePath, readSignature(absolutePath));
} }

View file

@ -197,4 +197,122 @@ describe("agent instructions bundle routes", () => {
expect.any(Object), 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", "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 () => { 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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
const workspace = path.join(root, "workspace"); 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, buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject, defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings, parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy, parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode, 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", () => { it("disables project execution workspace policy when the instance flag is off", () => {
expect( expect(
gateProjectExecutionWorkspacePolicy( gateProjectExecutionWorkspacePolicy(

View file

@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { agents } from "@paperclipai/db"; import type { agents } from "@paperclipai/db";
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { import {
buildExplicitResumeSessionOverride,
formatRuntimeWorkspaceWarningLog, formatRuntimeWorkspaceWarningLog,
prioritizeProjectWorkspaceCandidatesForRun, prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy, 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", () => { describe("formatRuntimeWorkspaceWarningLog", () => {
it("emits informational workspace warnings on stdout", () => { it("emits informational workspace warnings on stdout", () => {
expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({ 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, workspaceOperationService,
} from "../services/index.js"; } from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.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 { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js"; import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-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 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_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [
"instructionsBundleMode",
"instructionsRootPath",
"instructionsEntryFile",
"instructionsFilePath",
"agentsMdPath",
] as const;
const router = Router(); const router = Router();
const svc = agentService(db); const svc = agentService(db);
@ -303,6 +310,24 @@ export function agentRoutes(db: Db) {
return trimmed.length > 0 ? trimmed : null; 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 { function parseBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
if (typeof value === "number") { if (typeof value === "number") {
@ -830,17 +855,7 @@ export function agentRoutes(db: Db) {
}); });
router.get("/instance/scheduler-heartbeats", async (req, res) => { router.get("/instance/scheduler-heartbeats", async (req, res) => {
assertBoard(req); assertInstanceAdmin(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));
}
const rows = await db const rows = await db
.select({ .select({
@ -858,7 +873,6 @@ export function agentRoutes(db: Db) {
}) })
.from(agentsTable) .from(agentsTable)
.innerJoin(companies, eq(agentsTable.companyId, companies.id)) .innerJoin(companies, eq(agentsTable.companyId, companies.id))
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
.orderBy(companies.name, agentsTable.name); .orderBy(companies.name, agentsTable.name);
const items: InstanceSchedulerHeartbeatAgent[] = rows const items: InstanceSchedulerHeartbeatAgent[] = rows
@ -887,7 +901,6 @@ export function agentRoutes(db: Db) {
}; };
}) })
.filter((item) => .filter((item) =>
item.intervalSec > 0 &&
item.status !== "paused" && item.status !== "paused" &&
item.status !== "terminated" && item.status !== "terminated" &&
item.status !== "pending_approval", item.status !== "pending_approval",
@ -1689,6 +1702,8 @@ export function agentRoutes(db: Db) {
} }
const patchData = { ...(req.body as Record<string, unknown>) }; const patchData = { ...(req.body as Record<string, unknown>) };
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
delete patchData.replaceAdapterConfig;
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) { if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
const adapterConfig = asRecord(patchData.adapterConfig); const adapterConfig = asRecord(patchData.adapterConfig);
if (!adapterConfig) { if (!adapterConfig) {
@ -1710,9 +1725,31 @@ export function agentRoutes(db: Db) {
Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
if (touchesAdapterConfiguration) { 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(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( const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
requestedAdapterType, requestedAdapterType,
rawEffectiveAdapterConfig, 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) { export function assertCompanyAccess(req: Request, companyId: string) {
if (req.actor.type === "none") { if (req.actor.type === "none") {
throw unauthorized(); throw unauthorized();

View file

@ -233,6 +233,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const result = await svc.list(companyId, { const result = await svc.list(companyId, {
status: req.query.status as string | undefined, status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined,
participantAgentId: req.query.participantAgentId as string | undefined,
assigneeUserId, assigneeUserId,
touchedByUserId, touchedByUserId,
unreadForUserId, 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 { function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
const nextFiles = [...files]; const nextFiles = [...files];
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) { if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
@ -327,6 +383,36 @@ function applyBundleConfig(
return next; 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( async function writeBundleFiles(
rootPath: string, rootPath: string,
files: Record<string, string>, files: Record<string, string>,
@ -366,7 +452,7 @@ export function syncInstructionsBundleConfigFromFilePath(
export function agentInstructionsService() { export function agentInstructionsService() {
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> { 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, []); if (!state.rootPath) return toBundle(agent, state, []);
const stat = await statIfExists(state.rootPath); const stat = await statIfExists(state.rootPath);
if (!stat?.isDirectory()) { if (!stat?.isDirectory()) {
@ -381,7 +467,7 @@ export function agentInstructionsService() {
} }
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> { 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) { if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const content = asString(state.config[PROMPT_KEY]); const content = asString(state.config[PROMPT_KEY]);
if (content === null) throw notFound("Instructions file not found"); if (content === null) throw notFound("Instructions file not found");
@ -422,9 +508,14 @@ export function agentInstructionsService() {
agent: AgentLike, agent: AgentLike,
options?: { clearLegacyPromptTemplate?: boolean }, options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> { ): 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) { 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); const managedRoot = resolveManagedInstructionsRoot(agent);
@ -462,7 +553,7 @@ export function agentInstructionsService() {
clearLegacyPromptTemplate?: boolean; clearLegacyPromptTemplate?: boolean;
}, },
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> { ): 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 nextMode = input.mode ?? state.mode ?? "managed";
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile; const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
let nextRootPath: string; let nextRootPath: string;
@ -544,7 +635,8 @@ export function agentInstructionsService() {
bundle: AgentInstructionsBundle; bundle: AgentInstructionsBundle;
adapterConfig: Record<string, unknown>; adapterConfig: Record<string, unknown>;
}> { }> {
const state = deriveBundleState(agent); const derived = deriveBundleState(agent);
const state = await recoverManagedBundleState(agent, derived);
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file"); throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
} }
@ -555,8 +647,9 @@ export function agentInstructionsService() {
} }
const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath); const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
await fs.rm(absolutePath, { force: true }); await fs.rm(absolutePath, { force: true });
const bundle = await getBundle(agent); const adapterConfig = buildPersistedBundleConfig(derived, state);
return { bundle, adapterConfig: state.config }; const bundle = await getBundle({ ...agent, adapterConfig });
return { bundle, adapterConfig };
} }
async function exportFiles(agent: AgentLike): Promise<{ async function exportFiles(agent: AgentLike): Promise<{
@ -564,7 +657,7 @@ export function agentInstructionsService() {
entryFile: string; entryFile: string;
warnings: string[]; warnings: string[];
}> { }> {
const state = deriveBundleState(agent); const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (state.rootPath) { if (state.rootPath) {
const stat = await statIfExists(state.rootPath); const stat = await statIfExists(state.rootPath);
if (stat?.isDirectory()) { 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: { export function resolveExecutionWorkspaceMode(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null; projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null; issueSettings: IssueExecutionWorkspaceSettings | null;

View file

@ -45,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js";
import { import {
buildExecutionWorkspaceAdapterConfig, buildExecutionWorkspaceAdapterConfig,
gateProjectExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings, parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy, parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode, 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 { function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
if (!usage) return null; if (!usage) return null;
return { return {
@ -977,6 +1023,57 @@ export function heartbeatService(db: Db) {
return runtimeForRun?.sessionId ?? null; 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( async function resolveWorkspaceForRun(
agent: typeof agents.$inferSelect, agent: typeof agents.$inferSelect,
context: Record<string, unknown>, context: Record<string, unknown>,
@ -1920,9 +2017,18 @@ export function heartbeatService(db: Db) {
const resetTaskSession = shouldResetTaskSessionForWake(context); const resetTaskSession = shouldResetTaskSessionForWake(context);
const sessionResetReason = describeSessionResetReason(context); const sessionResetReason = describeSessionResetReason(context);
const taskSessionForRun = resetTaskSession ? null : taskSession; const taskSessionForRun = resetTaskSession ? null : taskSession;
const previousSessionParams = normalizeSessionParams( const explicitResumeSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), 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 config = parseObject(agent.adapterConfig);
const executionWorkspaceMode = resolveExecutionWorkspaceMode({ const executionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy, projectPolicy: projectExecutionWorkspacePolicy,
@ -2098,11 +2204,29 @@ export function heartbeatService(db: Db) {
cleanupReason: null, cleanupReason: null,
}); });
} }
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { if (issueId && persistedExecutionWorkspace) {
await issuesSvc.update(issueId, { const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
executionWorkspaceId: persistedExecutionWorkspace.id, const shouldSwitchIssueToExistingWorkspace =
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}), 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) { if (persistedExecutionWorkspace) {
context.executionWorkspaceId = persistedExecutionWorkspace.id; context.executionWorkspaceId = persistedExecutionWorkspace.id;
@ -2171,6 +2295,7 @@ export function heartbeatService(db: Db) {
} }
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId; const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
let previousSessionDisplayId = truncateDisplayId( let previousSessionDisplayId = truncateDisplayId(
explicitResumeSessionDisplayId ??
taskSessionForRun?.sessionDisplayId ?? taskSessionForRun?.sessionDisplayId ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ?? (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
readNonEmptyString(runtimeSessionParams?.sessionId) ?? readNonEmptyString(runtimeSessionParams?.sessionId) ??
@ -2782,7 +2907,9 @@ export function heartbeatService(db: Db) {
payload: promotedPayload, payload: promotedPayload,
}); });
const sessionBefore = await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey); const sessionBefore =
readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ??
await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
const now = new Date(); const now = new Date();
const newRun = await tx const newRun = await tx
.insert(heartbeatRuns) .insert(heartbeatRuns)
@ -2861,10 +2988,30 @@ export function heartbeatService(db: Db) {
triggerDetail, triggerDetail,
payload, payload,
}); });
const issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
const agent = await getAgent(agentId); const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found"); 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) => { const writeSkippedRequest = async (skipReason: string) => {
await db.insert(agentWakeupRequests).values({ await db.insert(agentWakeupRequests).values({
@ -2928,7 +3075,6 @@ export function heartbeatService(db: Db) {
if (issueId && !bypassIssueExecutionLock) { if (issueId && !bypassIssueExecutionLock) {
const agentNameKey = normalizeAgentNameKey(agent.name); const agentNameKey = normalizeAgentNameKey(agent.name);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const outcome = await db.transaction(async (tx) => { const outcome = await db.transaction(async (tx) => {
await tx.execute( await tx.execute(
@ -3279,8 +3425,6 @@ export function heartbeatService(db: Db) {
.returning() .returning()
.then((rows) => rows[0]); .then((rows) => rows[0]);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const newRun = await db const newRun = await db
.insert(heartbeatRuns) .insert(heartbeatRuns)
.values({ .values({

View file

@ -1,6 +1,7 @@
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
activityLog,
agents, agents,
assets, assets,
companies, companies,
@ -62,6 +63,7 @@ function applyStatusSideEffects(
export interface IssueFilters { export interface IssueFilters {
status?: string; status?: string;
assigneeAgentId?: string; assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string; assigneeUserId?: string;
touchedByUserId?: string; touchedByUserId?: string;
unreadForUserId?: 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) { function myLastCommentAtExpr(companyId: string, userId: string) {
return sql<Date | null>` return sql<Date | null>`
( (
@ -508,6 +534,9 @@ export function issueService(db: Db) {
if (filters?.assigneeAgentId) { if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
} }
if (filters?.participantAgentId) {
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
}
if (filters?.assigneeUserId) { if (filters?.assigneeUserId) {
conditions.push(eq(issues.assigneeUserId, 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`): 1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`):
```bash ```bash
pnpm paperclipai issue create \ npx paperclipai issue create \
--company-id "$PAPERCLIP_COMPANY_ID" \ --company-id "$PAPERCLIP_COMPANY_ID" \
--title "Self-test: assignment/watch flow" \ --title "Self-test: assignment/watch flow" \
--description "Temporary validation issue" \ --description "Temporary validation issue" \
@ -341,19 +341,19 @@ pnpm paperclipai issue create \
2. Trigger and watch a heartbeat for that assignee: 2. Trigger and watch a heartbeat for that assignee:
```bash ```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: 3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted:
```bash ```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: 4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior:
```bash ```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. 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 = 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"; "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 = 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({ export function CodexLocalConfigFields({
mode, mode,

View file

@ -18,6 +18,7 @@ export const issuesApi = {
status?: string; status?: string;
projectId?: string; projectId?: string;
assigneeAgentId?: string; assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string; assigneeUserId?: string;
touchedByUserId?: string; touchedByUserId?: string;
unreadForUserId?: string; unreadForUserId?: string;
@ -32,6 +33,7 @@ export const issuesApi = {
if (filters?.status) params.set("status", filters.status); if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); 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?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId); if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);

View file

@ -54,6 +54,17 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) {
return "shared_workspace"; 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 { interface IssuePropertiesProps {
issue: Issue; issue: Issue;
onUpdate: (data: Record<string, unknown>) => void; onUpdate: (data: Record<string, unknown>) => void;
@ -269,10 +280,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
? currentProject?.executionWorkspacePolicy ?? null ? currentProject?.executionWorkspacePolicy ?? null
: null; : null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const currentExecutionWorkspaceSelection =
issue.executionWorkspacePreference
?? issue.executionWorkspaceSettings?.mode
?? defaultExecutionWorkspaceModeForProject(currentProject);
const { data: reusableExecutionWorkspaces } = useQuery({ const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(companyId!, { queryKey: queryKeys.executionWorkspaces.list(companyId!, {
projectId: issue.projectId ?? undefined, projectId: issue.projectId ?? undefined,
@ -299,8 +306,16 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
} }
return Array.from(seen.values()); return Array.from(seen.values());
}, [reusableExecutionWorkspaces]); }, [reusableExecutionWorkspaces]);
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find( const selectedReusableExecutionWorkspace =
(workspace) => workspace.id === issue.executionWorkspaceId, 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) => { const projectLink = (id: string | null) => {
if (!id) return null; if (!id) return null;
@ -681,7 +696,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
> >
{EXECUTION_WORKSPACE_OPTIONS.map((option) => ( {EXECUTION_WORKSPACE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
? "Existing isolated workspace"
: option.label}
</option> </option>
))} ))}
</select> </select>

View file

@ -167,6 +167,9 @@ interface IssuesListProps {
issueLinkState?: unknown; issueLinkState?: unknown;
initialAssignees?: string[]; initialAssignees?: string[];
initialSearch?: string; initialSearch?: string;
searchFilters?: {
participantAgentId?: string;
};
onSearchChange?: (search: string) => void; onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void; onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
} }
@ -183,6 +186,7 @@ export function IssuesList({
issueLinkState, issueLinkState,
initialAssignees, initialAssignees,
initialSearch, initialSearch,
searchFilters,
onSearchChange, onSearchChange,
onUpdateIssue, onUpdateIssue,
}: IssuesListProps) { }: IssuesListProps) {
@ -240,8 +244,11 @@ export function IssuesList({
}, [scopedKey]); }, [scopedKey]);
const { data: searchedIssues = [] } = useQuery({ const { data: searchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), queryKey: [
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }), ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
],
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
}); });

View file

@ -573,9 +573,9 @@ export function AgentDetail() {
}); });
const { data: allIssues } = useQuery({ const { data: allIssues } = useQuery({
queryKey: queryKeys.issues.list(resolvedCompanyId!), queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"],
queryFn: () => issuesApi.list(resolvedCompanyId!), queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }),
enabled: !!resolvedCompanyId && needsDashboardData, enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData,
}); });
const { data: allAgents } = useQuery({ const { data: allAgents } = useQuery({
@ -593,7 +593,6 @@ export function AgentDetail() {
}); });
const assignedIssues = (allIssues ?? []) const assignedIssues = (allIssues ?? [])
.filter((i) => i.assigneeAgentId === agent?.id)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated"); 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="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent Issues</h3> <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; See All &rarr;
</Link> </Link>
</div> </div>
{assignedIssues.length === 0 ? ( {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"> <div className="border border-border rounded-lg">
{assignedIssues.slice(0, 10).map((issue) => ( {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 agents = heartbeatsQuery.data ?? [];
const activeCount = agents.filter((agent) => agent.schedulerActive).length; const activeCount = agents.filter((agent) => agent.schedulerActive).length;
const disabledCount = agents.length - activeCount; const disabledCount = agents.length - activeCount;
const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length;
const anyEnabled = enabledCount > 0;
const grouped = useMemo(() => { const grouped = useMemo(() => {
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>(); const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
@ -120,10 +175,27 @@ export function InstanceSettings() {
</p> </p>
</div> </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">{activeCount}</span> active</span>
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</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> <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> </div>
{actionError && ( {actionError && (

View file

@ -21,6 +21,7 @@ export function Issues() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const initialSearch = searchParams.get("q") ?? ""; const initialSearch = searchParams.get("q") ?? "";
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined); const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleSearchChange = useCallback((search: string) => { const handleSearchChange = useCallback((search: string) => {
clearTimeout(debounceRef.current); clearTimeout(debounceRef.current);
@ -86,8 +87,8 @@ export function Issues() {
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
const { data: issues, isLoading, error } = useQuery({ const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!), queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
queryFn: () => issuesApi.list(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
@ -117,6 +118,7 @@ export function Issues() {
initialSearch={initialSearch} initialSearch={initialSearch}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
searchFilters={participantAgentId ? { participantAgentId } : undefined}
/> />
); );
} }