From 16b2b84d84c8ea73c38875aa9ae7da33dd24e3b7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 20 Apr 2026 06:19:48 -0500 Subject: [PATCH] [codex] Improve agent runtime recovery and governance (#4086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The heartbeat runtime, agent import path, and agent configuration defaults determine whether work is dispatched safely and predictably. > - Several accumulated fixes all touched agent execution recovery, wake routing, import behavior, and runtime concurrency defaults. > - Those changes need to land together so the heartbeat service and agent creation defaults stay internally consistent. > - This pull request groups the runtime/governance changes from the split branch into one standalone branch. > - The benefit is safer recovery for stranded runs, bounded high-volume reads, imported-agent approval correctness, skill-template support, and a clearer default concurrency policy. ## What Changed - Fixed stranded continuation recovery so successful automatic retries are requeued instead of incorrectly blocking the issue. - Bounded high-volume issue/log reads across issue, heartbeat, agent, project, and workspace paths. - Fixed imported-agent approval and instruction-path permission handling. - Quarantined seeded worktree execution state during worktree provisioning. - Queued approval follow-up wakes and hardened SQL_ASCII heartbeat output handling. - Added reusable agent instruction templates for hiring flows. - Set the default max concurrent agent runs to five and updated related UI/tests/docs. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/company-portability.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-comment-wake-batching.test.ts server/src/__tests__/heartbeat-list.test.ts server/src/__tests__/issues-service.test.ts server/src/__tests__/agent-permissions-routes.test.ts packages/adapter-utils/src/server-utils.test.ts ui/src/lib/new-agent-runtime-config.test.ts` - Split integration check: merged this branch first, followed by the other [PAP-1614](/PAP/issues/PAP-1614) branches, with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: touches heartbeat recovery, queueing, and issue list bounds in central runtime paths. - Imported-agent and concurrency default behavior changes may affect existing automation that assumes one-at-a-time default runs. - No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- cli/src/__tests__/worktree.test.ts | 136 ++++++++ cli/src/commands/worktree.ts | 190 ++++++++++- doc/DEVELOPING.md | 2 + doc/SPEC-implementation.md | 2 +- .../adapter-utils/src/server-utils.test.ts | 11 + packages/adapter-utils/src/server-utils.ts | 28 +- packages/shared/src/constants.ts | 2 + packages/shared/src/index.ts | 1 + .../agent-permissions-routes.test.ts | 71 ++++ .../src/__tests__/company-portability.test.ts | 163 +++++++++- .../heartbeat-comment-wake-batching.test.ts | 109 +++++++ server/src/__tests__/heartbeat-list.test.ts | 24 +- .../heartbeat-process-recovery.test.ts | 83 +++++ server/src/__tests__/issues-service.test.ts | 10 +- server/src/adapters/utils.ts | 1 + server/src/routes/agents.ts | 64 +++- server/src/routes/execution-workspaces.ts | 15 +- server/src/routes/issues.ts | 11 +- server/src/routes/projects.ts | 15 +- server/src/services/agents.ts | 37 ++- server/src/services/company-portability.ts | 21 +- server/src/services/heartbeat.ts | 245 ++++++++++++-- server/src/services/index.ts | 8 +- server/src/services/instance-settings.ts | 11 +- server/src/services/issues.ts | 307 +++++++++++------- server/src/services/workspace-operations.ts | 6 +- server/src/services/workspace-runtime.ts | 27 +- skills/paperclip-create-agent/SKILL.md | 21 +- .../references/agent-instruction-templates.md | 138 ++++++++ skills/paperclip/SKILL.md | 2 +- tests/e2e/onboarding.spec.ts | 2 +- ui/src/api/agents.ts | 1 + ui/src/components/AgentConfigForm.tsx | 3 +- ui/src/hooks/useInboxBadge.ts | 2 + ui/src/lib/new-agent-runtime-config.test.ts | 4 +- ui/src/lib/new-agent-runtime-config.ts | 3 +- ui/src/pages/AgentDetail.tsx | 18 +- ui/src/pages/Inbox.tsx | 15 +- 38 files changed, 1569 insertions(+), 240 deletions(-) create mode 100644 skills/paperclip-create-agent/references/agent-instruction-templates.md diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ce0f85ad..98e2da77 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -3,12 +3,15 @@ import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { agents, authUsers, companies, createDb, + issueComments, + issues, projects, routines, routineTriggers, @@ -17,6 +20,7 @@ import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, pauseSeededScheduledRoutines, + quarantineSeededWorktreeExecutionState, readSourceAttachmentBody, rebindWorkspaceCwd, resolveSourceConfigPath, @@ -282,6 +286,138 @@ describe("worktree helpers", () => { expect(full.nullifyColumns).toEqual({}); }); + itEmbeddedPostgres("quarantines copied live execution state in seeded worktree databases", async () => { + const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-quarantine-"); + const db = createDb(tempDb.connectionString); + const companyId = randomUUID(); + const agentId = randomUUID(); + const idleAgentId = randomUUID(); + const inProgressIssueId = randomUUID(); + const todoIssueId = randomUUID(); + const reviewIssueId = randomUUID(); + const userIssueId = randomUUID(); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "WTQ", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { enabled: true, intervalSec: 60 }, + wakeOnDemand: true, + }, + permissions: {}, + }, + { + id: idleAgentId, + companyId, + name: "Reviewer", + role: "reviewer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { heartbeat: { enabled: false, intervalSec: 300 } }, + permissions: {}, + }, + ]); + await db.insert(issues).values([ + { + id: inProgressIssueId, + companyId, + title: "Copied in-flight issue", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: "WTQ-1", + executionAgentNameKey: "codexcoder", + executionLockedAt: new Date("2026-04-18T00:00:00.000Z"), + }, + { + id: todoIssueId, + companyId, + title: "Copied assigned todo issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 2, + identifier: "WTQ-2", + }, + { + id: reviewIssueId, + companyId, + title: "Copied assigned review issue", + status: "in_review", + priority: "medium", + assigneeAgentId: idleAgentId, + issueNumber: 3, + identifier: "WTQ-3", + }, + { + id: userIssueId, + companyId, + title: "Copied user issue", + status: "todo", + priority: "medium", + assigneeUserId: "user-1", + issueNumber: 4, + identifier: "WTQ-4", + }, + ]); + + await expect(quarantineSeededWorktreeExecutionState(tempDb.connectionString)).resolves.toEqual({ + disabledTimerHeartbeats: 1, + resetRunningAgents: 1, + quarantinedInProgressIssues: 1, + unassignedTodoIssues: 1, + unassignedReviewIssues: 1, + }); + + const [quarantinedAgent] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(quarantinedAgent?.status).toBe("idle"); + expect(quarantinedAgent?.runtimeConfig).toMatchObject({ + heartbeat: { enabled: false, intervalSec: 60 }, + wakeOnDemand: true, + }); + + const [inProgressIssue] = await db.select().from(issues).where(eq(issues.id, inProgressIssueId)); + expect(inProgressIssue?.status).toBe("blocked"); + expect(inProgressIssue?.assigneeAgentId).toBeNull(); + expect(inProgressIssue?.executionAgentNameKey).toBeNull(); + expect(inProgressIssue?.executionLockedAt).toBeNull(); + + const [todoIssue] = await db.select().from(issues).where(eq(issues.id, todoIssueId)); + expect(todoIssue?.status).toBe("todo"); + expect(todoIssue?.assigneeAgentId).toBeNull(); + + const [reviewIssue] = await db.select().from(issues).where(eq(issues.id, reviewIssueId)); + expect(reviewIssue?.status).toBe("in_review"); + expect(reviewIssue?.assigneeAgentId).toBeNull(); + + const [userIssue] = await db.select().from(issues).where(eq(issues.id, userIssueId)); + expect(userIssue?.status).toBe("todo"); + expect(userIssue?.assigneeUserId).toBe("user-1"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, inProgressIssueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("Quarantined during worktree seed"); + } finally { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); + await tempDb.cleanup(); + } + }, 20_000); + it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index d42cd9f3..bff59f50 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -93,6 +93,7 @@ type WorktreeInitOptions = { dbPort?: number; seed?: boolean; seedMode?: string; + preserveLiveWork?: boolean; force?: boolean; }; @@ -126,6 +127,7 @@ type WorktreeReseedOptions = { fromDataDir?: string; fromInstance?: string; seedMode?: string; + preserveLiveWork?: boolean; yes?: boolean; allowLiveTarget?: boolean; }; @@ -137,6 +139,7 @@ type WorktreeRepairOptions = { fromDataDir?: string; fromInstance?: string; seedMode?: string; + preserveLiveWork?: boolean; noSeed?: boolean; allowLiveTarget?: boolean; }; @@ -179,6 +182,8 @@ type CopiedGitHooksResult = { type SeedWorktreeDatabaseResult = { backupSummary: string; + pausedScheduledRoutines: number; + executionQuarantine: SeededWorktreeExecutionQuarantineSummary; reboundWorkspaces: Array<{ name: string; fromCwd: string; @@ -186,6 +191,14 @@ type SeedWorktreeDatabaseResult = { }>; }; +export type SeededWorktreeExecutionQuarantineSummary = { + disabledTimerHeartbeats: number; + resetRunningAgents: number; + quarantinedInProgressIssues: number; + unassignedTodoIssues: number; + unassignedReviewIssues: number; +}; + function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -198,6 +211,18 @@ function isCurrentSourceConfigPath(sourceConfigPath: string): boolean { return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath); } +function formatSeededWorktreeExecutionQuarantineSummary( + summary: SeededWorktreeExecutionQuarantineSummary, +): string { + return [ + `disabled timer heartbeats: ${summary.disabledTimerHeartbeats}`, + `reset running agents: ${summary.resetRunningAgents}`, + `quarantined in-progress issues: ${summary.quarantinedInProgressIssues}`, + `unassigned todo issues: ${summary.unassignedTodoIssues}`, + `unassigned review issues: ${summary.unassignedReviewIssues}`, + ].join(", "); +} + const WORKTREE_NAME_PREFIX = "paperclip-"; function resolveWorktreeMakeName(name: string): string { @@ -1119,6 +1144,133 @@ export async function pauseSeededScheduledRoutines(connectionString: string): Pr } } +const EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY: SeededWorktreeExecutionQuarantineSummary = { + disabledTimerHeartbeats: 0, + resetRunningAgents: 0, + quarantinedInProgressIssues: 0, + unassignedTodoIssues: 0, + unassignedReviewIssues: 0, +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isEnabledValue(value: unknown): boolean { + return value === true || value === "true" || value === 1 || value === "1"; +} + +function normalizeWorktreeRuntimeConfig(runtimeConfig: unknown): { + runtimeConfig: Record; + disabledTimerHeartbeat: boolean; + changed: boolean; +} { + const nextRuntimeConfig = isRecord(runtimeConfig) ? { ...runtimeConfig } : {}; + const heartbeat = isRecord(nextRuntimeConfig.heartbeat) ? { ...nextRuntimeConfig.heartbeat } : null; + if (!heartbeat) { + return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false }; + } + + const disabledTimerHeartbeat = isEnabledValue(heartbeat.enabled); + if (heartbeat.enabled !== false) { + heartbeat.enabled = false; + nextRuntimeConfig.heartbeat = heartbeat; + return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat, changed: true }; + } + + return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false }; +} + +export async function quarantineSeededWorktreeExecutionState( + connectionString: string, +): Promise { + const db = createDb(connectionString); + const summary = { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY }; + try { + await db.transaction(async (tx) => { + const seededAgents = await tx + .select({ + id: agents.id, + status: agents.status, + runtimeConfig: agents.runtimeConfig, + }) + .from(agents); + + for (const agent of seededAgents) { + const normalized = normalizeWorktreeRuntimeConfig(agent.runtimeConfig); + const nextStatus = agent.status === "running" ? "idle" : agent.status; + if (normalized.disabledTimerHeartbeat) { + summary.disabledTimerHeartbeats += 1; + } + if (agent.status === "running") { + summary.resetRunningAgents += 1; + } + if (normalized.changed || nextStatus !== agent.status) { + await tx + .update(agents) + .set({ + runtimeConfig: normalized.runtimeConfig, + status: nextStatus, + updatedAt: new Date(), + }) + .where(eq(agents.id, agent.id)); + } + } + + const affectedIssues = await tx + .select({ + id: issues.id, + companyId: issues.companyId, + status: issues.status, + }) + .from(issues) + .where( + and( + sql`${issues.assigneeAgentId} is not null`, + sql`${issues.assigneeUserId} is null`, + inArray(issues.status, ["todo", "in_progress", "in_review"]), + ), + ); + + for (const issue of affectedIssues) { + const nextStatus = issue.status === "in_progress" ? "blocked" : issue.status; + await tx + .update(issues) + .set({ + status: nextStatus, + assigneeAgentId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + executionWorkspaceId: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issue.id)); + + if (issue.status === "in_progress") { + summary.quarantinedInProgressIssues += 1; + await tx.insert(issueComments).values({ + companyId: issue.companyId, + issueId: issue.id, + body: + "Quarantined during worktree seed so copied in-flight work does not auto-run in this isolated instance. " + + "Reassign or unblock here only if you intentionally want the worktree instance to own this task.", + }); + } else if (issue.status === "todo") { + summary.unassignedTodoIssues += 1; + } else if (issue.status === "in_review") { + summary.unassignedReviewIssues += 1; + } + } + }); + + return summary; + } finally { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + async function seedWorktreeDatabase(input: { sourceConfigPath: string; sourceConfig: PaperclipConfig; @@ -1126,6 +1278,7 @@ async function seedWorktreeDatabase(input: { targetPaths: WorktreeLocalPaths; instanceId: string; seedMode: WorktreeSeedMode; + preserveLiveWork?: boolean; }): Promise { const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); @@ -1176,7 +1329,10 @@ async function seedWorktreeDatabase(input: { backupFile: backup.backupFile, }); await applyPendingMigrations(targetConnectionString); - await pauseSeededScheduledRoutines(targetConnectionString); + const executionQuarantine = input.preserveLiveWork + ? { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY } + : await quarantineSeededWorktreeExecutionState(targetConnectionString); + const pausedScheduledRoutines = await pauseSeededScheduledRoutines(targetConnectionString); const reboundWorkspaces = await rebindSeededProjectWorkspaces({ targetConnectionString, currentCwd: input.targetPaths.cwd, @@ -1184,6 +1340,8 @@ async function seedWorktreeDatabase(input: { return { backupSummary: formatDatabaseBackupResult(backup), + pausedScheduledRoutines, + executionQuarantine, reboundWorkspaces, }; } finally { @@ -1262,6 +1420,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd); let seedSummary: string | null = null; + let seedExecutionQuarantineSummary: SeededWorktreeExecutionQuarantineSummary | null = null; + let pausedScheduledRoutineCount: number | null = null; let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; if (opts.seed !== false) { if (!sourceConfig) { @@ -1279,8 +1439,11 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { targetPaths: paths, instanceId, seedMode, + preserveLiveWork: opts.preserveLiveWork, }); seedSummary = seeded.backupSummary; + seedExecutionQuarantineSummary = seeded.executionQuarantine; + pausedScheduledRoutineCount = seeded.pausedScheduledRoutines; reboundWorkspaceSummary = seeded.reboundWorkspaces; spinner.stop(`Seeded isolated worktree database (${seedMode}).`); } catch (error) { @@ -1303,6 +1466,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { if (seedSummary) { p.log.message(pc.dim(`Seed mode: ${seedMode}`)); p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); + if (opts.preserveLiveWork) { + p.log.warning("Preserved copied live work; this worktree instance may auto-run source-instance assignments."); + } else if (seedExecutionQuarantineSummary) { + p.log.message( + pc.dim(`Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(seedExecutionQuarantineSummary)}`), + ); + } + if (pausedScheduledRoutineCount != null) { + p.log.message(pc.dim(`Paused scheduled routines: ${pausedScheduledRoutineCount}`)); + } for (const rebound of reboundWorkspaceSummary) { p.log.message( pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), @@ -2947,11 +3120,20 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { targetPaths, instanceId: targetPaths.instanceId, seedMode, + preserveLiveWork: opts.preserveLiveWork, }); spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`); p.log.message(pc.dim(`Source: ${source.configPath}`)); p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`)); p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`)); + if (opts.preserveLiveWork) { + p.log.warning("Preserved copied live work; this worktree instance may auto-run source-instance assignments."); + } else { + p.log.message( + pc.dim(`Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(seeded.executionQuarantine)}`), + ); + } + p.log.message(pc.dim(`Paused scheduled routines: ${seeded.pausedScheduledRoutines}`)); for (const rebound of seeded.reboundWorkspaces) { p.log.message( pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), @@ -3015,6 +3197,7 @@ export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promis fromConfig: source.configPath, to: target.rootPath, seedMode, + preserveLiveWork: opts.preserveLiveWork, yes: true, allowLiveTarget: opts.allowLiveTarget, }); @@ -3047,6 +3230,7 @@ export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promis fromInstance: opts.fromInstance, seed: opts.noSeed ? false : true, seedMode, + preserveLiveWork: opts.preserveLiveWork, force: true, }); } finally { @@ -3070,6 +3254,7 @@ export function registerWorktreeCommands(program: Command): void { .option("--server-port ", "Preferred server port", (value) => Number(value)) .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false) .option("--no-seed", "Skip database seeding from the source instance") .option("--force", "Replace existing repo-local config and isolated instance data", false) .action(worktreeMakeCommand); @@ -3086,6 +3271,7 @@ export function registerWorktreeCommands(program: Command): void { .option("--server-port ", "Preferred server port", (value) => Number(value)) .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false) .option("--no-seed", "Skip database seeding from the source instance") .option("--force", "Replace existing repo-local config and isolated instance data", false) .action(worktreeInitCommand); @@ -3125,6 +3311,7 @@ export function registerWorktreeCommands(program: Command): void { .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config") .option("--seed-mode ", "Seed profile: minimal or full (default: full)", "full") + .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false) .option("--yes", "Skip the destructive confirmation prompt", false) .option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false) .action(worktreeReseedCommand); @@ -3138,6 +3325,7 @@ export function registerWorktreeCommands(program: Command): void { .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config (default: default)") .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false) .option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false) .option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false) .action(worktreeRepairCommand); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 225ae9ff..099c6359 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -209,6 +209,8 @@ Seed modes: - `full` makes a full logical clone of the source instance - `--no-seed` creates an empty isolated instance +Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments. + After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. `pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 873381d5..c4008034 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -619,7 +619,7 @@ Per-agent schedule fields in `adapter_config`: - `enabled` boolean - `intervalSec` integer (minimum 30) -- `maxConcurrentRuns` fixed at `1` for V1 +- `maxConcurrentRuns` integer; new agents default to `5` Scheduler must skip invocation when: diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 4f776185..f49dfd45 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; import { + appendWithByteCap, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, renderPaperclipWakePrompt, runChildProcess, @@ -111,6 +112,16 @@ describe("runChildProcess", () => { }); }); +describe("appendWithByteCap", () => { + it("keeps valid UTF-8 when trimming through multibyte text", () => { + const output = appendWithByteCap("prefix ", "hello — world", 7); + + expect(output).not.toContain("\uFFFD"); + expect(Buffer.from(output, "utf8").toString("utf8")).toBe(output); + expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7); + }); +}); + describe("renderPaperclipWakePrompt", () => { it("keeps the default local-agent prompt action-oriented", () => { expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat"); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 9078f47b..2eec1892 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -191,6 +191,22 @@ export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYT return combined.length > cap ? combined.slice(combined.length - cap) : combined; } +export function appendWithByteCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) { + const combined = prev + chunk; + const bytes = Buffer.byteLength(combined, "utf8"); + if (bytes <= cap) return combined; + + const buffer = Buffer.from(combined, "utf8"); + let start = Math.max(0, bytes - cap); + while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start += 1; + return buffer.subarray(start).toString("utf8"); +} + +function resumeReadable(readable: { resume: () => unknown; destroyed?: boolean } | null | undefined) { + if (!readable || readable.destroyed) return; + readable.resume(); +} + export function resolvePathValue(obj: Record, dottedPath: string) { const parts = dottedPath.split("."); let cursor: unknown = obj; @@ -1283,19 +1299,27 @@ export async function runChildProcess( : null; child.stdout?.on("data", (chunk: unknown) => { + const readable = child.stdout; + if (!readable) return; + readable.pause(); const text = String(chunk); stdout = appendWithCap(stdout, text); logChain = logChain .then(() => opts.onLog("stdout", text)) - .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); + .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")) + .finally(() => resumeReadable(readable)); }); child.stderr?.on("data", (chunk: unknown) => { + const readable = child.stderr; + if (!readable) return; + readable.pause(); const text = String(chunk); stderr = appendWithCap(stderr, text); logChain = logChain .then(() => opts.onLog("stderr", text)) - .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); + .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")) + .finally(() => resumeReadable(readable)); }); const stdin = child.stdin; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 65cf53cc..8b74632f 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -66,6 +66,8 @@ export const AGENT_ROLE_LABELS: Record = { general: "General", }; +export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5; + export const AGENT_ICON_NAMES = [ "bot", "cpu", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 82d90755..dc856292 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,7 @@ export { AGENT_ADAPTER_TYPES, AGENT_ROLES, AGENT_ROLE_LABELS, + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, AGENT_ICON_NAMES, ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUSES, diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index bbb0c2a9..978e7c2e 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -34,6 +34,7 @@ const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), list: vi.fn(), create: vi.fn(), + activatePendingApproval: vi.fn(), updatePermissions: vi.fn(), getChainOfCommand: vi.fn(), resolveByReference: vi.fn(), @@ -108,6 +109,7 @@ function registerModuleMocks() { companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, + ISSUE_LIST_DEFAULT_LIMIT: 500, issueApprovalService: () => mockIssueApprovalService, issueService: () => mockIssueService, logActivity: mockLogActivity, @@ -166,6 +168,7 @@ describe("agent permission routes", () => { mockAgentService.getChainOfCommand.mockResolvedValue([]); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent }); mockAgentService.create.mockResolvedValue(baseAgent); + mockAgentService.activatePendingApproval.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent); mockAccessService.getMembership.mockResolvedValue({ id: "membership-1", @@ -480,6 +483,7 @@ describe("agent permission routes", () => { heartbeat: { enabled: false, intervalSec: 3600, + maxConcurrentRuns: 5, }, }, }), @@ -517,12 +521,73 @@ describe("agent permission routes", () => { heartbeat: { enabled: false, intervalSec: 3600, + maxConcurrentRuns: 5, }, }, }), ); }); + it("allows board users to directly approve pending agents", async () => { + const pendingAgent = { + ...baseAgent, + status: "pending_approval", + }; + const approvedAgent = { + ...baseAgent, + status: "idle", + }; + mockAgentService.getById.mockResolvedValue(pendingAgent); + mockAgentService.activatePendingApproval.mockResolvedValue({ + agent: approvedAgent, + activated: true, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/approve`) + .send({}); + + expect(res.status).toBe(200); + expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith(agentId); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "user", + actorId: "board-user", + action: "agent.approved", + entityType: "agent", + entityId: agentId, + details: { source: "agent_detail" }, + })); + }); + + it("rejects direct approval for agents that are not pending approval", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/approve`) + .send({}); + + expect(res.status).toBe(409); + expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "agent.approved", + })); + }); + it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ { @@ -615,6 +680,12 @@ describe("agent permission routes", () => { status: "todo", }, ]); + expect(mockIssueService.list).toHaveBeenCalledWith(companyId, { + touchedByUserId: "board-user", + inboxArchivedByUserId: "board-user", + status: "backlog,todo,in_progress,in_review,blocked,done", + limit: 500, + }); }); it("rejects heartbeat cancellation outside the caller company scope", async () => { diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index f7a6e88b..f5858a3e 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1539,13 +1539,13 @@ describe("company portability", () => { expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ projectId: "project-created", title: "Monday Review", - assigneeAgentId: null, + assigneeAgentId: "agent-created", priority: "high", status: "paused", concurrencyPolicy: "always_enqueue", catchUpPolicy: "enqueue_missed_with_cap", }), expect.any(Object)); - expect(result.warnings).toContain( + expect(result.warnings).not.toContain( "Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.", ); expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2); @@ -2132,6 +2132,7 @@ describe("company portability", () => { runtimeConfig: { heartbeat: { enabled: false, + maxConcurrentRuns: 5, }, }, }); @@ -2210,6 +2211,7 @@ describe("company portability", () => { runtimeConfig: { heartbeat: { enabled: false, + maxConcurrentRuns: 5, }, }, })); @@ -2489,7 +2491,7 @@ describe("company portability", () => { expect(agentSvc.create).not.toHaveBeenCalled(); }); - it("imports new agents through approval and adapter-config normalization", async () => { + it("imports new agents as active while preserving future hire approval settings", async () => { const portability = companyPortabilityService({} as any); const exported = await portability.exportBundle("company-1", { include: { @@ -2549,7 +2551,10 @@ describe("company portability", () => { adapterConfig: expect.objectContaining({ normalized: true, }), - status: "pending_approval", + status: "idle", + })); + expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({ + requireBoardApprovalForNewAgents: true, })); }); @@ -2614,4 +2619,154 @@ describe("company portability", () => { }, })); }); + + it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: true, projects: false, issues: false }, + }); + + // Simulate existing agents so collision detection triggers rename + agentSvc.list.mockResolvedValue([ + { id: "existing-1", name: "ClaudeCoder", status: "idle", role: "engineer", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + ]); + + const preview = await portability.previewImport({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: ["claudecoder"], + collisionStrategy: "rename", + nameOverrides: { claudecoder: "ClaudeCoder" }, + }); + + // The override reverts the renamed agent back to its original collision name. + // This is a known limitation: nameOverrides bypass collision checks. + const plan = preview.plan.agentPlans.find((p) => p.slug === "claudecoder"); + expect(plan).toBeDefined(); + expect(plan!.action).toBe("create"); + expect(plan!.plannedName).toBe("ClaudeCoder"); + }); + + it("handles circular reportsTo chains without infinite recursion during export", async () => { + const portability = companyPortabilityService({} as any); + + agentSvc.list.mockResolvedValue([ + { + id: "agent-a", name: "AgentA", status: "idle", role: "engineer", title: null, icon: null, + reportsTo: "agent-b", capabilities: null, adapterType: "claude_local", + adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null, + }, + { + id: "agent-b", name: "AgentB", status: "idle", role: "manager", title: null, icon: null, + reportsTo: "agent-a", capabilities: null, adapterType: "claude_local", + adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null, + }, + ]); + agentInstructionsSvc.exportFiles.mockResolvedValue({ + files: { "AGENTS.md": "Instructions" }, entryFile: "AGENTS.md", warnings: [], + }); + + // Export should complete without infinite recursion in org chart building + const exported = await portability.exportBundle("company-1", { + include: { company: true, agents: true, projects: false, issues: false }, + }); + + expect(exported.manifest.agents).toHaveLength(2); + // Both agents should appear in the export + const slugs = exported.manifest.agents.map((a) => a.slug); + expect(slugs).toContain("agenta"); + expect(slugs).toContain("agentb"); + }); + + it("resolves issue assignee to existing agent when agent is skipped", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([{ + id: "project-1", companyId: "company-1", name: "TestProject", urlKey: "testproject", + description: null, leadAgentId: null, targetDate: null, color: null, status: "planned", + executionWorkspacePolicy: null, archivedAt: null, workspaces: [], + }]); + issueSvc.list.mockResolvedValue([{ + id: "issue-1", companyId: "company-1", title: "Test task", identifier: "PAP-1", + description: "A test task", status: "todo", priority: "medium", + assigneeAgentId: "agent-1", projectId: "project-1", projectWorkspaceId: null, + goalId: null, parentId: null, billingCode: null, labelIds: [], + executionWorkspaceSettings: null, assigneeAdapterOverrides: null, metadata: null, + }]); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: true, projects: true, issues: true }, + }); + + // Re-import into same company with skip collision strategy + // Both agents exist so both will be skipped; the existing agent should resolve for issue assignment + agentSvc.list.mockResolvedValue([ + { id: "agent-1", name: "ClaudeCoder", status: "idle", role: "engineer", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + { id: "agent-2", name: "CMO", status: "idle", role: "cmo", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + ]); + projectSvc.list.mockResolvedValue([]); + issueSvc.list.mockResolvedValue([]); + projectSvc.create.mockResolvedValue({ id: "project-new", companyId: "company-1", urlKey: "testproject" }); + issueSvc.create.mockResolvedValue({ id: "issue-new", identifier: "PAP-100" }); + + const result = await portability.importBundle({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: true, projects: true, issues: true }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: "all", + collisionStrategy: "skip", + }, "user-1"); + + // Both agents should be skipped (already exist) + const agentResult = result.agents.find((a) => a.slug === "claudecoder"); + expect(agentResult).toBeDefined(); + expect(agentResult!.action).toBe("skipped"); + + // Issue should still be created and reference the existing agent + expect(issueSvc.create).toHaveBeenCalled(); + const issueCreateCall = issueSvc.create.mock.calls[0]; + // The assigneeAgentId should resolve to the existing agent via existingSlugToAgentId + expect(issueCreateCall[1]).toEqual(expect.objectContaining({ + assigneeAgentId: "agent-1", + })); + }); + + it("handles a package with only skills (no agents or projects)", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: false, projects: false, issues: false, skills: true }, + expandReferencedSkills: true, + }); + + expect(exported.manifest.agents).toHaveLength(0); + expect(exported.manifest.projects).toHaveLength(0); + expect(exported.manifest.issues).toHaveLength(0); + // Skills should still be exported + expect(exported.manifest.skills.length).toBeGreaterThanOrEqual(0); + }); + + it("preview import detects no agents to import when agents are excluded", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: true, agents: true, projects: false, issues: false }, + }); + + agentSvc.list.mockResolvedValue([]); + + const preview = await portability.previewImport({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: false, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(preview.plan.agentPlans).toHaveLength(0); + expect(preview.plan.projectPlans).toHaveLength(0); + expect(preview.plan.issuePlans).toHaveLength(0); + }); }); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 417d47d8..03ef7a93 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -236,6 +236,115 @@ describe("heartbeat comment wake batching", () => { } }); + it("defers approval-approved wakes for a running issue so the assignee resumes after the run", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CEO", + role: "ceo", + status: "running", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "running", + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_assigned", + }, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Hire an agent", + status: "blocked", + priority: "medium", + assigneeAgentId: agentId, + executionRunId: runId, + executionAgentNameKey: "ceo", + executionLockedAt: new Date(), + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const followupRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "approval_approved", + payload: { + issueId, + approvalId: "approval-1", + approvalStatus: "approved", + }, + contextSnapshot: { + issueId, + taskId: issueId, + approvalId: "approval-1", + approvalStatus: "approved", + wakeReason: "approval_approved", + }, + requestedByActorType: "user", + requestedByActorId: "local-board", + }); + + expect(followupRun).toBeNull(); + + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + + expect(deferred).not.toBeNull(); + expect(deferred?.reason).toBe("issue_execution_deferred"); + expect(deferred?.payload).toMatchObject({ + issueId, + approvalId: "approval-1", + approvalStatus: "approved", + }); + expect((deferred?.payload as Record)._paperclipWakeContext).toMatchObject({ + issueId, + taskId: issueId, + approvalId: "approval-1", + approvalStatus: "approved", + wakeReason: "approval_approved", + }); + + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(1); + expect(runs[0]?.id).toBe(runId); + }); + it("batches deferred comment wakes and forwards the ordered batch to the next run", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); diff --git a/server/src/__tests__/heartbeat-list.test.ts b/server/src/__tests__/heartbeat-list.test.ts index ccc32c41..69a2f252 100644 --- a/server/src/__tests__/heartbeat-list.test.ts +++ b/server/src/__tests__/heartbeat-list.test.ts @@ -5,7 +5,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { heartbeatService } from "../services/heartbeat.ts"; +import { boundHeartbeatRunEventPayloadForStorage, heartbeatService } from "../services/heartbeat.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -202,3 +202,25 @@ describeEmbeddedPostgres("heartbeat list", () => { expect(result).not.toHaveProperty("nestedHuge"); }); }); + +describe("heartbeat run event payload bounding", () => { + it("truncates oversized adapter metadata before storage", () => { + const payload = boundHeartbeatRunEventPayloadForStorage({ + adapterType: "codex_local", + prompt: "x".repeat(40_000), + context: { + issueId: "issue-1", + memory: "y".repeat(40_000), + }, + }); + + expect(payload.adapterType).toBe("codex_local"); + expect(typeof payload.prompt).toBe("string"); + expect((payload.prompt as string).length).toBeLessThan(20_000); + expect(payload.prompt).toContain("[truncated"); + expect(payload.context).toMatchObject({ + issueId: "issue-1", + }); + expect(JSON.stringify(payload).length).toBeLessThan(45_000); + }); +}); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index a85fe711..9d2ffeb2 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -702,6 +702,56 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } }); + it("does not continue seeded in-progress work that has no run linkage", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Seeded in-flight work", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + checkoutRunId: null, + executionRunId: null, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + startedAt: new Date("2026-03-19T00:00:00.000Z"), + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.dispatchRequeued).toBe(0); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + expect(result.skipped).toBe(1); + + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(0); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(issue?.status).toBe("in_progress"); + expect(issue?.executionRunId).toBeNull(); + }); + it("classifies actionable plan-only recovery and enqueues one liveness continuation", async () => { mockAdapterExecute.mockResolvedValueOnce({ exitCode: 0, @@ -824,6 +874,39 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced."); }); + it("re-enqueues continuation when the latest automatic continuation succeeded without closing the issue", async () => { + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + retryReason: "issue_continuation_needed", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.continuationRequeued).toBe(1); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("in_progress"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(0); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.id).toBeTruthy(); + expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("issue_continuation_needed"); + if (retryRun) { + await waitForRunToSettle(heartbeat, retryRun.id); + } + }); + it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => { const { issueId, runId } = await seedStrandedIssueFixture({ status: "todo", diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 8661e2ab..b778db68 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -22,12 +22,20 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.ts"; -import { issueService } from "../services/issues.ts"; +import { clampIssueListLimit, ISSUE_LIST_MAX_LIMIT, issueService } from "../services/issues.ts"; import { buildProjectMentionHref } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +describe("issue list limit helpers", () => { + it("clamps untrusted issue-list limits to the server maximum", () => { + expect(clampIssueListLimit(0)).toBe(1); + expect(clampIssueListLimit(25.9)).toBe(25); + expect(clampIssueListLimit(ISSUE_LIST_MAX_LIMIT + 10)).toBe(ISSUE_LIST_MAX_LIMIT); + }); +}); + async function ensureIssueRelationsTable(db: ReturnType) { await db.execute(sql.raw(` CREATE TABLE IF NOT EXISTS "issue_relations" ( diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index 96931b0f..da3767dd 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -24,6 +24,7 @@ export const asBoolean = serverUtils.asBoolean; export const asStringArray = serverUtils.asStringArray; export const parseJson = serverUtils.parseJson; export const appendWithCap = serverUtils.appendWithCap; +export const appendWithByteCap = serverUtils.appendWithByteCap; export const resolvePathValue = serverUtils.resolvePathValue; export const renderTemplate = serverUtils.renderTemplate; export const redactEnvForLogs = serverUtils.redactEnvForLogs; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 401d7ac6..9d0eddc6 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -7,6 +7,7 @@ import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, agentMineInboxQuerySchema, + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -37,6 +38,7 @@ import { companySkillService, budgetService, heartbeatService, + ISSUE_LIST_DEFAULT_LIMIT, issueApprovalService, issueService, logActivity, @@ -75,6 +77,15 @@ import { } from "../services/default-agent-instructions.js"; import { getTelemetryClient } from "../telemetry.js"; +const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; +const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; + +function readRunLogLimitBytes(value: unknown) { + const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES); + if (!Number.isFinite(parsed)) return RUN_LOG_DEFAULT_LIMIT_BYTES; + return Math.max(1, Math.min(RUN_LOG_MAX_LIMIT_BYTES, Math.trunc(parsed))); +} + export function agentRoutes(db: Db) { // Legacy hardcoded maps — used as fallback when adapter module does not // declare capability flags explicitly. @@ -514,6 +525,9 @@ export function agentRoutes(db: Db) { if (parseBooleanLike(heartbeat.enabled) == null) { heartbeat.enabled = false; } + if (parseNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } normalizedRuntimeConfig.heartbeat = heartbeat; return normalizedRuntimeConfig; @@ -1168,6 +1182,7 @@ export function agentRoutes(db: Db) { assigneeAgentId: req.actor.agentId, status: "todo,in_progress,blocked", includeRoutineExecutions: true, + limit: ISSUE_LIST_DEFAULT_LIMIT, }); res.json( @@ -1198,6 +1213,7 @@ export function agentRoutes(db: Db) { touchedByUserId: query.userId, inboxArchivedByUserId: query.userId, status: query.status, + limit: ISSUE_LIST_DEFAULT_LIMIT, }); res.json(rows); @@ -1682,6 +1698,10 @@ export function agentRoutes(db: Db) { }); router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { + if (req.actor.type !== "board") { + throw forbidden("Only board-authenticated callers can manage instructions path or bundle configuration"); + } + const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { @@ -2098,6 +2118,42 @@ export function agentRoutes(db: Db) { res.json(agent); }); + router.post("/agents/:id/approve", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await getAccessibleAgent(req, res, id); + if (!existing) { + return; + } + if (existing.status !== "pending_approval") { + res.status(409).json({ error: "Only pending approval agents can be approved" }); + return; + } + + const approval = await svc.activatePendingApproval(id); + if (!approval) { + res.status(404).json({ error: "Agent not found" }); + return; + } + if (!approval.activated) { + res.status(409).json({ error: "Only pending approval agents can be approved" }); + return; + } + const { agent } = approval; + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.approved", + entityType: "agent", + entityId: agent.id, + details: { source: "agent_detail" }, + }); + + res.json(agent); + }); + router.post("/agents/:id/terminate", async (req, res) => { assertBoard(req); const id = req.params.id as string; @@ -2492,10 +2548,10 @@ export function agentRoutes(db: Db) { assertCompanyAccess(req, run.companyId); const offset = Number(req.query.offset ?? 0); - const limitBytes = Number(req.query.limitBytes ?? 256000); + const limitBytes = readRunLogLimitBytes(req.query.limitBytes); const result = await heartbeat.readLog(run, { offset: Number.isFinite(offset) ? offset : 0, - limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, + limitBytes, }); res.set("Cache-Control", "no-cache, no-store"); @@ -2527,10 +2583,10 @@ export function agentRoutes(db: Db) { assertCompanyAccess(req, operation.companyId); const offset = Number(req.query.offset ?? 0); - const limitBytes = Number(req.query.limitBytes ?? 256000); + const limitBytes = readRunLogLimitBytes(req.query.limitBytes); const result = await workspaceOperations.readLog(operationId, { offset: Number.isFinite(offset) ? offset : 0, - limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, + limitBytes, }); res.set("Cache-Control", "no-cache, no-store"); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 20e80c53..07895b92 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -28,6 +28,9 @@ import { collectExecutionWorkspaceCommandPaths, } from "./workspace-command-authz.js"; import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; +import { appendWithCap } from "../adapters/utils.js"; + +const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; export function executionWorkspaceRoutes(db: Db) { const router = Router(); @@ -209,8 +212,8 @@ export function executionWorkspaceRoutes(db: Db) { executionWorkspaceId: existing.id, }); let runtimeServiceCount = existing.runtimeServices?.length ?? 0; - const stdout: string[] = []; - const stderr: string[] = []; + let stdout = ""; + let stderr = ""; const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", @@ -310,8 +313,8 @@ export function executionWorkspaceRoutes(db: Db) { } const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - if (stream === "stdout") stdout.push(chunk); - else stderr.push(chunk); + if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); + else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; if (action === "stop" || action === "restart") { @@ -382,8 +385,8 @@ export function executionWorkspaceRoutes(db: Db) { return { status: "succeeded", - stdout: stdout.join(""), - stderr: stderr.join(""), + stdout, + stderr, system: action === "stop" ? "Stopped execution workspace runtime services.\n" diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5c5067cb..03a35a47 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -40,7 +40,10 @@ import { heartbeatService, instanceSettingsService, issueApprovalService, + ISSUE_LIST_DEFAULT_LIMIT, + ISSUE_LIST_MAX_LIMIT, issueService, + clampIssueListLimit, documentService, logActivity, projectService, @@ -618,8 +621,10 @@ export function issueRoutes( ? req.actor.userId : unreadForUserFilterRaw; const rawLimit = req.query.limit as string | undefined; - const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : null; - const limit = parsedLimit ?? undefined; + const parsedLimit = rawLimit !== undefined && /^\d+$/.test(rawLimit) + ? Number.parseInt(rawLimit, 10) + : null; + const limit = parsedLimit === null ? ISSUE_LIST_DEFAULT_LIMIT : clampIssueListLimit(parsedLimit); if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -638,7 +643,7 @@ export function issueRoutes( return; } if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) { - res.status(400).json({ error: "limit must be a positive integer" }); + res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` }); return; } diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index f4d3e848..036b0de6 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -29,6 +29,9 @@ import { } from "./workspace-command-authz.js"; import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { getTelemetryClient } from "../telemetry.js"; +import { appendWithCap } from "../adapters/utils.js"; + +const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; export function projectRoutes(db: Db) { const router = Router(); @@ -377,8 +380,8 @@ export function projectRoutes(db: Db) { const actor = getActorInfo(req); const recorder = workspaceOperations.createRecorder({ companyId: project.companyId }); let runtimeServiceCount = workspace.runtimeServices?.length ?? 0; - const stdout: string[] = []; - const stderr: string[] = []; + let stdout = ""; + let stderr = ""; const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", @@ -440,8 +443,8 @@ export function projectRoutes(db: Db) { } const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - if (stream === "stdout") stdout.push(chunk); - else stderr.push(chunk); + if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); + else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; if (action === "stop" || action === "restart") { @@ -514,8 +517,8 @@ export function projectRoutes(db: Db) { return { status: "succeeded", - stdout: stdout.join(""), - stderr: stderr.join(""), + stdout, + stderr, system: action === "stop" ? "Stopped project workspace runtime services.\n" diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 131578de..512d0e89 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -16,7 +16,7 @@ import { issues, issueComments, } from "@paperclipai/db"; -import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared"; +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { normalizeAgentPermissions } from "./agent-permissions.js"; import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; @@ -114,6 +114,25 @@ function hasConfigPatchFields(data: Partial) { return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field)); } +function parseFiniteNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeRuntimeConfigForNewAgent(runtimeConfig: unknown): Record { + const normalizedRuntimeConfig = isPlainRecord(runtimeConfig) ? { ...runtimeConfig } : {}; + const heartbeat = isPlainRecord(normalizedRuntimeConfig.heartbeat) + ? { ...normalizedRuntimeConfig.heartbeat } + : {}; + if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } + normalizedRuntimeConfig.heartbeat = heartbeat; + return normalizedRuntimeConfig; +} + function diffConfigSnapshot( before: AgentConfigSnapshot, after: AgentConfigSnapshot, @@ -398,9 +417,10 @@ export function agentService(db: Db) { const role = data.role ?? "general"; const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); + const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig); const created = await db .insert(agents) - .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions }) + .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions, runtimeConfig }) .returning() .then((rows) => rows[0]); @@ -506,18 +526,19 @@ export function agentService(db: Db) { }, activatePendingApproval: async (id: string) => { - const existing = await getById(id); - if (!existing) return null; - if (existing.status !== "pending_approval") return existing; - const updated = await db .update(agents) .set({ status: "idle", updatedAt: new Date() }) - .where(eq(agents.id, id)) + .where(and(eq(agents.id, id), eq(agents.status, "pending_approval"))) .returning() .then((rows) => rows[0] ?? null); - return updated ? normalizeAgentRow(updated) : null; + if (updated) { + return { agent: normalizeAgentRow(updated), activated: true }; + } + + const existing = await getById(id); + return existing ? { agent: existing, activated: false } : null; }, updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 305f6a76..bbc7d4b6 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -31,6 +31,7 @@ import type { RoutineVariable, } from "@paperclipai/shared"; import { + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, ISSUE_PRIORITIES, ISSUE_STATUSES, PROJECT_STATUSES, @@ -590,7 +591,7 @@ const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [ { path: ["heartbeat", "wakeOnAssignment"], value: true }, { path: ["heartbeat", "wakeOnAutomation"], value: true }, { path: ["heartbeat", "wakeOnDemand"], value: true }, - { path: ["heartbeat", "maxConcurrentRuns"], value: 3 }, + { path: ["heartbeat", "maxConcurrentRuns"], value: AGENT_DEFAULT_MAX_CONCURRENT_RUNS }, ]; const ADAPTER_DEFAULT_RULES_BY_TYPE: Record> = { @@ -741,10 +742,20 @@ function clonePortableRecord(value: unknown) { return structuredClone(value) as Record; } +function parseFiniteNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + function disableImportedTimerHeartbeat(runtimeConfig: unknown) { const next = clonePortableRecord(runtimeConfig) ?? {}; const heartbeat = isPlainRecord(next.heartbeat) ? { ...next.heartbeat } : {}; heartbeat.enabled = false; + if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } next.heartbeat = heartbeat; return next; } @@ -4209,13 +4220,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { continue; } - const requiresApproval = - typeof targetCompany.requireBoardApprovalForNewAgents === "boolean" - ? targetCompany.requireBoardApprovalForNewAgents - : include.company - ? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true) - : true; - const createdStatus = requiresApproval ? "pending_approval" : "idle"; + const createdStatus = "idle"; let created = await agents.create(targetCompany.id, { ...patch, status: createdStatus, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f6a423f9..75987703 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4,8 +4,14 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; -import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig, RunLivenessState } from "@paperclipai/shared"; +import { + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + type BillingType, + type ExecutionWorkspace, + type ExecutionWorkspaceConfig, + type RunLivenessState, +} from "@paperclipai/shared"; import { agents, agentRuntimeState, @@ -31,7 +37,7 @@ import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; import { getServerAdapter, runningProcesses } from "../adapters/index.js"; import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec, UsageSummary } from "../adapters/index.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; -import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; +import { parseObject, asBoolean, asNumber, appendWithByteCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { costService } from "./costs.js"; import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; @@ -104,7 +110,11 @@ import { extractSkillMentionIds } from "@paperclipai/shared"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024; -const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; +const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024; +const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50; +const MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS = 100; +const MAX_RUN_EVENT_PAYLOAD_DEPTH = 6; +const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const WAKE_COMMENT_IDS_KEY = "wakeCommentIds"; @@ -119,6 +129,8 @@ const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; const execFile = promisify(execFileCallback); const ACTIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"] as const; +const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const; +const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set(["approval_approved"]); const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", "codex_local", @@ -504,6 +516,15 @@ const heartbeatRunSafeColumns = { resultJson: heartbeatRunSafeResultJsonColumn, } as const; +const heartbeatRunSqlAsciiSafeColumns = { + ...getTableColumns(heartbeatRuns), + processGroupId: heartbeatRunProcessGroupIdColumn, + error: sql`NULL`.as("error"), + resultJson: sql | null>`NULL`.as("resultJson"), + stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), + stderrExcerpt: sql`NULL`.as("stderrExcerpt"), +} as const; + const heartbeatRunLogAccessColumns = { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, @@ -529,7 +550,81 @@ const heartbeatRunIssueSummaryColumns = { } as const; function appendExcerpt(prev: string, chunk: string) { - return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES); + return appendWithByteCap(prev, chunk, MAX_EXCERPT_BYTES); +} + +function truncateRunEventString(value: string) { + if (value.length <= MAX_RUN_EVENT_PAYLOAD_STRING_CHARS) return value; + const omittedChars = value.length - MAX_RUN_EVENT_PAYLOAD_STRING_CHARS; + return `${value.slice(0, MAX_RUN_EVENT_PAYLOAD_STRING_CHARS)}\n[truncated ${omittedChars} chars]`; +} + +function boundRunEventValue(value: unknown, depth: number, seen: WeakSet): unknown { + if (typeof value === "string") { + return truncateRunEventString(value); + } + if ( + value === null + || typeof value === "number" + || typeof value === "boolean" + ) { + return value; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { + if (depth >= MAX_RUN_EVENT_PAYLOAD_DEPTH) { + return { + _truncated: true, + type: "array", + originalLength: value.length, + }; + } + const bounded = value + .slice(0, MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS) + .map((entry) => boundRunEventValue(entry, depth + 1, seen)); + if (value.length > MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS) { + bounded.push({ + _truncated: true, + omittedItems: value.length - MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS, + }); + } + return bounded; + } + if (typeof value !== "object" || value === undefined) { + return null; + } + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + const entries = Object.entries(value as Record); + if (depth >= MAX_RUN_EVENT_PAYLOAD_DEPTH) { + const bounded = { + _truncated: true, + type: "object", + keys: entries.map(([key]) => key).slice(0, 20), + }; + seen.delete(value); + return bounded; + } + + const out: Record = {}; + for (const [key, entryValue] of entries.slice(0, MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS)) { + out[key] = boundRunEventValue(entryValue, depth + 1, seen); + } + if (entries.length > MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS) { + out._truncated = true; + out._omittedKeys = entries.length - MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS; + } + seen.delete(value); + return out; +} + +export function boundHeartbeatRunEventPayloadForStorage(payload: Record): Record { + const bounded = boundRunEventValue(payload, 0, new WeakSet()); + return parseObject(bounded) ?? { _truncated: true }; } function redactInlineBase64ImageData(chunk: string) { @@ -716,6 +811,22 @@ function summarizeRunFailureForIssueComment( return null; } +function didAutomaticRecoveryFail( + latestRun: Pick | null, + expectedRetryReason: "assignment_recovery" | "issue_continuation_needed", +) { + if (!latestRun) return false; + + const latestContext = parseObject(latestRun.contextSnapshot); + const latestRetryReason = readNonEmptyString(latestContext.retryReason); + return ( + latestRetryReason === expectedRetryReason && + UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES.includes( + latestRun.status as (typeof UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES)[number], + ) + ); +} + function normalizeLedgerBillingType(value: unknown): BillingType { const raw = readNonEmptyString(value); switch (raw) { @@ -1095,6 +1206,15 @@ function shouldAutoCheckoutIssueForWake(input: { return true; } +function shouldQueueFollowupForRunningIssueWake(input: { + contextSnapshot: Record | null | undefined; + wakeCommentId: string | null; +}) { + if (input.wakeCommentId) return true; + const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason); + return Boolean(wakeReason && RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP.has(wakeReason)); +} + function isCheckoutConflictError(error: unknown): boolean { return error instanceof HttpError && error.status === 409 && error.message === "Issue checkout conflict"; } @@ -1577,6 +1697,26 @@ export function heartbeatService(db: Db) { cancelWorkForScope: cancelBudgetScopeWork, }; const budgets = budgetService(db, budgetHooks); + let unsafeTextProjectionPromise: Promise | null = null; + + async function hasUnsafeTextProjectionDatabase() { + if (!unsafeTextProjectionPromise) { + unsafeTextProjectionPromise = db + .execute(sql`select current_setting('server_encoding') as server_encoding`) + .then((rows) => { + const first = Array.isArray(rows) ? rows[0] : null; + const serverEncoding = typeof first === "object" && first !== null + ? (first as Record).server_encoding + : null; + return typeof serverEncoding === "string" && serverEncoding.toUpperCase() === "SQL_ASCII"; + }) + .catch((err) => { + logger.warn({ err }, "failed to inspect database server encoding; using conservative heartbeat result projection"); + return true; + }); + } + return unsafeTextProjectionPromise; + } async function getAgent(agentId: string) { return db @@ -1587,8 +1727,15 @@ export function heartbeatService(db: Db) { } async function getRun(runId: string, opts?: { unsafeFullResultJson?: boolean }) { + const safeForLegacyEncoding = !opts?.unsafeFullResultJson && await hasUnsafeTextProjectionDatabase(); return db - .select(opts?.unsafeFullResultJson ? getTableColumns(heartbeatRuns) : heartbeatRunSafeColumns) + .select( + opts?.unsafeFullResultJson + ? getTableColumns(heartbeatRuns) + : safeForLegacyEncoding + ? heartbeatRunSqlAsciiSafeColumns + : heartbeatRunSafeColumns, + ) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); @@ -2393,9 +2540,12 @@ export function heartbeatService(db: Db) { const sanitizedMessage = event.message ? redactCurrentUserText(event.message, currentUserRedactionOptions) : event.message; - const sanitizedPayload = event.payload - ? redactCurrentUserValue(event.payload, currentUserRedactionOptions) + const boundedPayload = event.payload + ? boundHeartbeatRunEventPayloadForStorage(event.payload) : event.payload; + const sanitizedPayload = boundedPayload + ? redactCurrentUserValue(boundedPayload, currentUserRedactionOptions) + : boundedPayload; await db.insert(heartbeatRunEvents).values({ companyId: run.companyId, @@ -3484,16 +3634,13 @@ export function heartbeatService(db: Db) { } const latestRun = await getLatestIssueRun(issue.companyId, issue.id); - const latestContext = parseObject(latestRun?.contextSnapshot); - const latestRetryReason = readNonEmptyString(latestContext.retryReason); - if (issue.status === "todo") { if (!latestRun || latestRun.status === "succeeded") { result.skipped += 1; continue; } - if (latestRetryReason === "assignment_recovery") { + if (didAutomaticRecoveryFail(latestRun, "assignment_recovery")) { const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, @@ -3530,7 +3677,12 @@ export function heartbeatService(db: Db) { continue; } - if (latestRetryReason === "issue_continuation_needed") { + if (!latestRun && !issue.checkoutRunId && !issue.executionRunId) { + result.skipped += 1; + continue; + } + + if (didAutomaticRecoveryFail(latestRun, "issue_continuation_needed")) { const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, @@ -5137,12 +5289,12 @@ export function heartbeatService(db: Db) { normalizeAgentNameKey(executionAgent?.name); const isSameExecutionAgent = Boolean(executionAgentNameKey) && executionAgentNameKey === agentNameKey; - const shouldQueueFollowupForCommentWake = - Boolean(wakeCommentId) && + const shouldQueueFollowupForRunningWake = + shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }) && activeExecutionRun.status === "running" && isSameExecutionAgent; - if (isSameExecutionAgent && !shouldQueueFollowupForCommentWake) { + if (isSameExecutionAgent && !shouldQueueFollowupForRunningWake) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( activeExecutionRun.contextSnapshot, enrichedContextSnapshot, @@ -5319,12 +5471,14 @@ export function heartbeatService(db: Db) { const sameScopeRunningRun = activeRuns.find( (candidate) => candidate.status === "running" && isSameTaskScope(runTaskKey(candidate), taskKey), ); - const shouldQueueFollowupForCommentWake = - Boolean(wakeCommentId) && Boolean(sameScopeRunningRun) && !sameScopeQueuedRun; + const shouldQueueFollowupForRunningWake = + Boolean(sameScopeRunningRun) && + !sameScopeQueuedRun && + shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }); const coalescedTargetRun = sameScopeQueuedRun ?? - (shouldQueueFollowupForCommentWake ? null : sameScopeRunningRun ?? null); + (shouldQueueFollowupForRunningWake ? null : sameScopeRunningRun ?? null); if (coalescedTargetRun) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( @@ -5646,12 +5800,21 @@ export function heartbeatService(db: Db) { return { list: async (companyId: string, agentId?: string, limit?: number) => { + const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase(); const query = db - .select({ - ...heartbeatRunListColumns, - ...heartbeatRunListContextColumns, - ...heartbeatRunListResultColumns, - }) + .select( + safeForLegacyEncoding + ? { + ...heartbeatRunListColumns, + error: sql`NULL`.as("error"), + ...heartbeatRunListContextColumns, + } + : { + ...heartbeatRunListColumns, + ...heartbeatRunListContextColumns, + ...heartbeatRunListResultColumns, + }, + ) .from(heartbeatRuns) .where( agentId @@ -5679,7 +5842,15 @@ export function heartbeatService(db: Db) { resultCostUsd, resultCostUsdCamel, ...rest - } = row; + } = row as typeof row & { + resultSummary?: string | null; + resultResult?: string | null; + resultMessage?: string | null; + resultError?: string | null; + resultTotalCostUsd?: string | null; + resultCostUsd?: string | null; + resultCostUsdCamel?: string | null; + }; return { ...rest, @@ -5693,15 +5864,17 @@ export function heartbeatService(db: Db) { wakeSource: contextWakeSource, wakeTriggerDetail: contextWakeTriggerDetail, }), - resultJson: summarizeHeartbeatRunListResultJson({ - summary: resultSummary, - result: resultResult, - message: resultMessage, - error: resultError, - totalCostUsd: resultTotalCostUsd, - costUsd: resultCostUsd, - costUsdCamel: resultCostUsdCamel, - }), + resultJson: safeForLegacyEncoding + ? null + : summarizeHeartbeatRunListResultJson({ + summary: resultSummary, + result: resultResult, + message: resultMessage, + error: resultError, + totalCostUsd: resultTotalCostUsd, + costUsd: resultCostUsd, + costUsdCamel: resultCostUsdCamel, + }), }; }); }, @@ -5810,7 +5983,9 @@ export function heartbeatService(db: Db) { store: run.logStore, logRef: run.logRef, ...result, - content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()), + // Run-log chunks are already redacted before they are appended to the store. + // Rewriting the full chunk again on every poll creates avoidable string copies. + content: result.content, }; }, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index e999a9cc..6536f893 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -12,7 +12,13 @@ export { refreshIssueContinuationSummary, } from "./issue-continuation-summary.js"; export { projectService } from "./projects.js"; -export { issueService, type IssueFilters } from "./issues.js"; +export { + clampIssueListLimit, + ISSUE_LIST_DEFAULT_LIMIT, + ISSUE_LIST_MAX_LIMIT, + issueService, + type IssueFilters, +} from "./issues.js"; export { issueApprovalService } from "./issue-approvals.js"; export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 65a12632..06e6f9df 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -85,7 +85,16 @@ export function instanceSettingsService(db: Db) { }) .returning(); - return created; + if (created) return created; + + const raced = await db + .select() + .from(instanceSettings) + .where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY)) + .then((rows) => rows[0] ?? null); + if (raced) return raced; + + throw new Error("Failed to initialize instance settings row"); } return { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 75133b1b..11344d7b 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -38,6 +38,9 @@ import { getDefaultCompanyGoal } from "./goals.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; +export const ISSUE_LIST_DEFAULT_LIMIT = 500; +export const ISSUE_LIST_MAX_LIMIT = 1000; +const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; const MAX_CHILD_COMPLETION_SUMMARIES = 20; const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500; @@ -106,6 +109,10 @@ type IssueUserCommentStats = { myLastCommentAt: Date | null; lastExternalCommentAt: Date | null; }; +type IssueReadStat = { + issueId: string; + myLastReadAt: Date | null; +}; type IssueLastActivityStat = { issueId: string; latestCommentAt: Date | null; @@ -158,6 +165,18 @@ function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); } +export function clampIssueListLimit(limit: number): number { + return Math.min(ISSUE_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit))); +} + +function chunkList(values: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +} + function truncateInlineSummary(value: string | null | undefined, maxChars = CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS) { const normalized = value?.trim(); if (!normalized) return null; @@ -494,20 +513,22 @@ function latestIssueActivityAt(...values: Array> { const map = new Map(); if (issueIds.length === 0) return map; - const rows = await dbOrTx - .select({ - issueId: issueLabels.issueId, - label: labels, - }) - .from(issueLabels) - .innerJoin(labels, eq(issueLabels.labelId, labels.id)) - .where(inArray(issueLabels.issueId, issueIds)) - .orderBy(asc(labels.name), asc(labels.id)); + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueLabels.issueId, + label: labels, + }) + .from(issueLabels) + .innerJoin(labels, eq(issueLabels.labelId, labels.id)) + .where(inArray(issueLabels.issueId, issueIdChunk)) + .orderBy(asc(labels.name), asc(labels.id)); - for (const row of rows) { - const existing = map.get(row.issueId); - if (existing) existing.push(row.label); - else map.set(row.issueId, [row.label]); + for (const row of rows) { + const existing = map.get(row.issueId); + if (existing) existing.push(row.label); + else map.set(row.issueId, [row.label]); + } } return map; } @@ -537,27 +558,29 @@ async function activeRunMapForIssues( .filter((id): id is string => id != null); if (runIds.length === 0) return map; - const rows = await dbOrTx - .select({ - id: heartbeatRuns.id, - status: heartbeatRuns.status, - agentId: heartbeatRuns.agentId, - invocationSource: heartbeatRuns.invocationSource, - triggerDetail: heartbeatRuns.triggerDetail, - startedAt: heartbeatRuns.startedAt, - finishedAt: heartbeatRuns.finishedAt, - createdAt: heartbeatRuns.createdAt, - }) - .from(heartbeatRuns) - .where( - and( - inArray(heartbeatRuns.id, runIds), - inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), - ), - ); + for (const runIdChunk of chunkList([...new Set(runIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where( + and( + inArray(heartbeatRuns.id, runIdChunk), + inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), + ), + ); - for (const row of rows) { - map.set(row.id, row); + for (const row of rows) { + map.set(row.id, row); + } } return map; } @@ -617,6 +640,131 @@ function withActiveRuns( })); } +async function userCommentStatsForIssues( + dbOrTx: any, + companyId: string, + userId: string, + issueIds: string[], +): Promise { + const stats: IssueUserCommentStats[] = []; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueComments.issueId, + myLastCommentAt: sql` + MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} THEN ${issueComments.createdAt} END) + `, + lastExternalCommentAt: sql` + MAX( + CASE + WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId} + THEN ${issueComments.createdAt} + END + ) + `, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIdChunk), + ), + ) + .groupBy(issueComments.issueId); + stats.push(...rows); + } + return stats; +} + +async function userReadStatsForIssues( + dbOrTx: any, + companyId: string, + userId: string, + issueIds: string[], +): Promise { + const stats: IssueReadStat[] = []; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueReadStates.issueId, + myLastReadAt: issueReadStates.lastReadAt, + }) + .from(issueReadStates) + .where( + and( + eq(issueReadStates.companyId, companyId), + eq(issueReadStates.userId, userId), + inArray(issueReadStates.issueId, issueIdChunk), + ), + ); + stats.push(...rows); + } + return stats; +} + +async function lastActivityStatsForIssues( + dbOrTx: any, + companyId: string, + issueIds: string[], +): Promise { + const byIssueId = new Map(); + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const [commentRows, logRows] = await Promise.all([ + dbOrTx + .select({ + issueId: issueComments.issueId, + latestCommentAt: sql`MAX(${issueComments.createdAt})`, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIdChunk), + ), + ) + .groupBy(issueComments.issueId), + dbOrTx + .select({ + issueId: activityLog.entityId, + latestLogAt: sql`MAX(${activityLog.createdAt})`, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, issueIdChunk), + sql`${activityLog.action} NOT IN (${sql.join( + ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), + sql`, `, + )})`, + ), + ) + .groupBy(activityLog.entityId), + ]); + + for (const row of commentRows) { + byIssueId.set(row.issueId, { + issueId: row.issueId, + latestCommentAt: row.latestCommentAt, + latestLogAt: null, + }); + } + for (const row of logRows) { + const existing = byIssueId.get(row.issueId); + if (existing) existing.latestLogAt = row.latestLogAt; + else { + byIssueId.set(row.issueId, { + issueId: row.issueId, + latestCommentAt: null, + latestLogAt: row.latestLogAt, + }); + } + } + } + return [...byIssueId.values()]; +} + export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); @@ -1105,99 +1253,12 @@ export function issueService(db: Db) { const issueIds = withRuns.map((row) => row.id); const [statsRows, readRows, lastActivityRows] = await Promise.all([ contextUserId - ? db - .select({ - issueId: issueComments.issueId, - myLastCommentAt: sql` - MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END) - `, - lastExternalCommentAt: sql` - MAX( - CASE - WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId} - THEN ${issueComments.createdAt} - END - ) - `, - }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, companyId), - inArray(issueComments.issueId, issueIds), - ), - ) - .groupBy(issueComments.issueId) + ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), contextUserId - ? db - .select({ - issueId: issueReadStates.issueId, - myLastReadAt: issueReadStates.lastReadAt, - }) - .from(issueReadStates) - .where( - and( - eq(issueReadStates.companyId, companyId), - eq(issueReadStates.userId, contextUserId), - inArray(issueReadStates.issueId, issueIds), - ), - ) + ? userReadStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), - Promise.all([ - db - .select({ - issueId: issueComments.issueId, - latestCommentAt: sql`MAX(${issueComments.createdAt})`, - }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, companyId), - inArray(issueComments.issueId, issueIds), - ), - ) - .groupBy(issueComments.issueId), - db - .select({ - issueId: activityLog.entityId, - latestLogAt: sql`MAX(${activityLog.createdAt})`, - }) - .from(activityLog) - .where( - and( - eq(activityLog.companyId, companyId), - eq(activityLog.entityType, "issue"), - inArray(activityLog.entityId, issueIds), - sql`${activityLog.action} NOT IN (${sql.join( - ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), - sql`, `, - )})`, - ), - ) - .groupBy(activityLog.entityId), - ]).then(([commentRows, logRows]) => { - const byIssueId = new Map(); - for (const row of commentRows) { - byIssueId.set(row.issueId, { - issueId: row.issueId, - latestCommentAt: row.latestCommentAt, - latestLogAt: null, - }); - } - for (const row of logRows) { - const existing = byIssueId.get(row.issueId); - if (existing) existing.latestLogAt = row.latestLogAt; - else { - byIssueId.set(row.issueId, { - issueId: row.issueId, - latestCommentAt: null, - latestLogAt: row.latestLogAt, - }); - } - } - return [...byIssueId.values()]; - }), + lastActivityStatsForIssues(db, companyId, issueIds), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); diff --git a/server/src/services/workspace-operations.ts b/server/src/services/workspace-operations.ts index b20a9ed7..9509e982 100644 --- a/server/src/services/workspace-operations.ts +++ b/server/src/services/workspace-operations.ts @@ -250,9 +250,9 @@ export function workspaceOperationService(db: Db) { store: operation.logStore, logRef: operation.logRef, ...result, - content: redactCurrentUserText(result.content, { - enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, - }), + // Workspace-operation log chunks are sanitized before append-time storage. + // Returning the stored chunk avoids another whole-string rewrite per poll. + content: result.content, }; }, }; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 87a093a5..f3a556c0 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -413,32 +413,33 @@ function formatCommandForDisplay(command: string, args: string[]) { .join(" "); } +function trimToLastBytes(value: string, limit: number) { + const byteLength = Buffer.byteLength(value, "utf8"); + if (byteLength <= limit) return value; + return Buffer.from(value, "utf8").subarray(byteLength - limit).toString("utf8"); +} + function createProcessOutputCapture(maxBytes: number): ProcessOutputAccumulator { const limit = Math.max(1, Math.trunc(maxBytes)); - let chunks: string[] = []; + let text = ""; let truncated = false; let totalBytes = 0; return { append(chunk: string) { if (!chunk) return; - chunks.push(chunk); totalBytes += Buffer.byteLength(chunk, "utf8"); - let currentBytes = chunks.reduce((sum, value) => sum + Buffer.byteLength(value, "utf8"), 0); - if (currentBytes <= limit) return; - - const combined = Buffer.from(chunks.join(""), "utf8"); - const tail = combined.subarray(Math.max(0, combined.length - limit)).toString("utf8"); - chunks = [tail]; - truncated = true; - currentBytes = Buffer.byteLength(tail, "utf8"); - if (currentBytes > limit) { - chunks = [Buffer.from(tail, "utf8").subarray(Math.max(0, currentBytes - limit)).toString("utf8")]; + const combined = text + chunk; + if (Buffer.byteLength(combined, "utf8") <= limit) { + text = combined; + return; } + + text = trimToLastBytes(combined, limit); + truncated = true; }, finish(): ProcessOutputCapture { - const text = chunks.join(""); if (!truncated) { return { text, diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index 878b255b..50dab4e2 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -49,14 +49,19 @@ curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-configura -H "Authorization: Bearer $PAPERCLIP_API_KEY" ``` -5. Discover allowed agent icons and pick one that matches the role. +5. Read the reusable agent instruction templates before drafting the hire. If the role matches an existing pattern, start from that template and adapt it to the company, manager, adapter, and workspace. + +Reference: +`skills/paperclip-create-agent/references/agent-instruction-templates.md` + +6. Discover allowed agent icons and pick one that matches the role. ```sh curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ -H "Authorization: Bearer $PAPERCLIP_API_KEY" ``` -6. Draft the new hire config: +7. Draft the new hire config: - role/title/name - icon (required in practice; use one from `/llms/agent-icons.txt`) - reporting line (`reportsTo`) @@ -65,10 +70,12 @@ curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ - adapter and runtime config aligned to this environment - leave timer heartbeats off by default; only set `runtimeConfig.heartbeat.enabled=true` with an `intervalSec` when the role genuinely needs scheduled recurring work or the user explicitly asked for it - capabilities -- run prompt in adapter config (`promptTemplate` where applicable). For coding or execution agents, include the Paperclip execution contract: start actionable work in the same heartbeat; do not stop at a plan unless planning was requested; leave durable progress with a clear next action; use child issues for long or parallel delegated work instead of polling; mark blocked work with owner/action; respect budget, pause/cancel, approval gates, and company boundaries. +- run prompt in adapter config (`promptTemplate` where applicable) +- for coding or execution agents, include the Paperclip execution contract: start actionable work in the same heartbeat; do not stop at a plan unless planning was requested; leave durable progress with a clear next action; use child issues for long or parallel delegated work instead of polling; mark blocked work with owner/action; respect budget, pause/cancel, approval gates, and company boundaries. +- instruction text such as `AGENTS.md`, using a reusable template when one fits; for local managed-bundle adapters, put the adapted `AGENTS.md` content in `adapterConfig.promptTemplate` unless you are a board user intentionally managing bundle paths/files - source issue linkage (`sourceIssueId` or `sourceIssueIds`) when this hire came from an issue -7. Submit hire request. +8. Submit hire request. ```sh curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \ @@ -89,7 +96,7 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-h }' ``` -8. Handle governance state: +9. Handle governance state: - if response has `approval`, hire is `pending_approval` - monitor and discuss on approval thread - when the board approves, you will be woken with `PAPERCLIP_APPROVAL_ID`; read linked issues and close/comment follow-up @@ -133,6 +140,7 @@ Before sending a hire request: - if the role needs skills, make sure they already exist in the company library or install them first using the Paperclip company-skills workflow - Reuse proven config patterns from related agents where possible. +- Reuse a proven instruction template when the role matches one in `skills/paperclip-create-agent/references/agent-instruction-templates.md`; update placeholders and remove irrelevant guidance before submitting the hire. - Set a concrete `icon` from `/llms/agent-icons.txt` so the new hire is identifiable in org and task views. - Avoid secrets in plain text unless required by adapter behavior. - Ensure reporting line is correct and in-company. @@ -142,3 +150,6 @@ Before sending a hire request: For endpoint payload shapes and full examples, read: `skills/paperclip-create-agent/references/api-reference.md` + +For reusable `AGENTS.md` starting points, read: +`skills/paperclip-create-agent/references/agent-instruction-templates.md` diff --git a/skills/paperclip-create-agent/references/agent-instruction-templates.md b/skills/paperclip-create-agent/references/agent-instruction-templates.md new file mode 100644 index 00000000..d9174f7e --- /dev/null +++ b/skills/paperclip-create-agent/references/agent-instruction-templates.md @@ -0,0 +1,138 @@ +# Agent Instruction Templates + +Use this reference when hiring or creating agents. Start from an existing pattern when the requested role is close, then adapt the text to the company, reporting line, adapter, workspace, permissions, and task type. + +These templates are intentionally separate from the main Paperclip heartbeat skill so the core wake procedure stays short. + +## Index + +| Template | Use when hiring | Typical adapter | +|---|---|---| +| `Coder` | Software engineers who implement code, debug issues, write tests, and coordinate with QA/CTO | `codex_local`, `claude_local`, `cursor`, or another coding adapter | +| `QA` | QA engineers who reproduce bugs, validate fixes, capture screenshots, and report actionable findings | `claude_local` or another browser-capable adapter | + +## How To Apply A Template + +1. Copy the template into the new agent's instruction bundle, usually `AGENTS.md`. For hire requests using local managed-bundle adapters, this usually means setting the adapted template as `adapterConfig.promptTemplate`; Paperclip materializes it into `AGENTS.md`. +2. Replace placeholders like `{{companyName}}`, `{{managerTitle}}`, `{{issuePrefix}}`, and URLs. +3. Remove tools or workflows the target adapter cannot use. +4. Keep the Paperclip heartbeat requirement and task-comment requirement. +5. Add role-specific skills or reference files only when they are actually installed or bundled. + +## Template: Coder + +Recommended role fields: + +- `name`: `Coder`, `CodexCoder`, `ClaudeCoder`, or a model/tool-specific name +- `role`: `engineer` +- `title`: `Software Engineer` +- `icon`: `code` +- `capabilities`: `Implements coding tasks, writes and edits code, debugs issues, adds focused tests, and coordinates with QA and engineering leadership.` + +`AGENTS.md`: + +```md +You are agent {{agentName}} (Coder / Software Engineer) at {{companyName}}. + +When you wake up, follow the Paperclip skill. It contains the full heartbeat procedure. + +You are a software engineer. Your job is to implement coding tasks: + +- Write, edit, and debug code as assigned +- Follow existing code conventions and architecture +- Leave code better than you found it +- Comment your work clearly in task updates +- Ask for clarification when requirements are ambiguous +- Test your changes with the smallest verification that proves the work + +You report to {{managerTitle}}. Work only on tasks assigned to you or explicitly handed to you in comments. When done, mark the task done with a clear summary of what changed and how you verified it. + +Commit things in logical commits as you go when the work is good. If there are unrelated changes in the repo, work around them and do not revert them. Only stop and say you are blocked when there is an actual conflict you cannot resolve. + +Make sure you know the success condition for each task. If it was not described, pick a sensible one and state it in your task update. Before finishing, check whether the success condition was achieved. If it was not, keep iterating or escalate with a concrete blocker. + +Keep the work moving until it is done. If you need QA to review it, ask QA. If you need your manager to review it, ask them. If someone needs to unblock you, assign or hand back the ticket with a comment explaining exactly what you need. + +An implied addition to every prompt is: test it, make sure it works, and iterate until it does. If it is a shell script, run a safe version. If it is code, run the smallest relevant tests or checks. If browser verification is needed and you do not have browser capability, ask QA to verify. + +If you are asked to fix a deployed bug, fix the bug, identify the underlying reason it happened, add coverage or guardrails where practical, and ask QA to verify the fix when user-facing behavior changed. + +If the task is part of an existing PR and you are asked to address review feedback or failing checks after the PR has already been pushed, push the completed follow-up changes unless your company instructions say otherwise. + +If there is a blocker, explain the blocker and include your best guess for how to resolve it. Do not only say that it is blocked. + +When you run tests, do not default to the entire test suite. Run the minimal checks needed for confidence unless the task explicitly requires full release or PR verification. + +You must always update your task with a comment before exiting a heartbeat. +``` + +## Template: QA + +Recommended role fields: + +- `name`: `QA` +- `role`: `qa` +- `title`: `QA Engineer` +- `icon`: `bug` +- `capabilities`: `Owns manual and automated QA workflows, reproduces defects, validates fixes end-to-end, captures evidence, and reports concise actionable findings.` + +`AGENTS.md`: + +```md +You are agent {{agentName}} (QA) at {{companyName}}. + +When you wake up, follow the Paperclip skill. It contains the full heartbeat procedure. + +You are the QA Engineer. Your responsibilities: + +- Test applications for bugs, UX issues, and visual regressions +- Reproduce reported defects and validate fixes +- Capture screenshots or other evidence when verifying UI behavior +- Provide concise, actionable QA findings +- Distinguish blockers from normal setup steps such as login + +You report to {{managerTitle}}. Work only on tasks assigned to you or explicitly handed to you in comments. + +Keep the work moving until it is done. If you need someone to review it, ask them. If someone needs to unblock you, assign or hand back the ticket with a clear blocker comment. + +You must always update your task with a comment. + +## Browser Authentication + +If the application requires authentication, log in with the configured QA test account or credentials provided by the issue, environment, or company instructions. Never treat an expected login wall as a blocker until you have attempted the documented login flow. + +For authenticated browser tasks: + +1. Open the target URL. +2. If redirected to an auth page, log in with the available QA credentials. +3. Wait for the target page to finish loading. +4. Continue the test from the authenticated state. + +## Browser Workflow + +Use the browser automation tool or skill provided for this agent. Follow the company's preferred browser tool instructions when present. + +For UI verification tasks: + +1. Open the target URL. +2. Exercise the requested workflow. +3. Capture a screenshot or other evidence when the UI result matters. +4. Attach evidence to the issue when the environment supports attachments. +5. Post a comment with what was verified. + +## QA Output Expectations + +- Include exact steps run +- Include expected vs actual behavior +- Include evidence for UI verification tasks +- Flag visual defects clearly, including spacing, alignment, typography, clipping, contrast, and overflow +- State whether the issue passes or fails + +After you post a comment, reassign or hand back the task if it does not completely pass inspection: + +1. Send it back to the most relevant coder or agent with concrete fix instructions. +2. Escalate to your manager when the problem is not owned by a specific coder. +3. Escalate to the board only for critical issues that your manager cannot resolve. + +Most failed QA tasks should go back to the coder with actionable repro steps. If the task passes, mark it done. +``` diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 7d127f56..2eecf53e 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -312,7 +312,7 @@ If you are asked to create or manage routines you MUST read: - **@-mentions** (`@AgentName` in comments) trigger heartbeats — use sparingly, they cost budget. - **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only. - **Escalate** via `chainOfCommand` when stuck. Reassign to manager or create a task for them. -- **Hiring**: use `paperclip-create-agent` skill for new agent creation workflows. +- **Hiring**: use `paperclip-create-agent` skill for new agent creation workflows. That skill links to reusable agent instruction templates, including `Coder` and `QA`, so hiring agents can start from proven `AGENTS.md` patterns without bloating this heartbeat skill. - **Commit Co-author**: if you make a git commit you MUST add EXACTLY `Co-Authored-By: Paperclip ` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip ` ## Comment Style (Required) diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index adb68db3..2a6d6a43 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -84,7 +84,7 @@ test.describe("Onboarding wizard", () => { intervalSec: 300, wakeOnDemand: false, cooldownSec: 10, - maxConcurrentRuns: 1, + maxConcurrentRuns: 5, }, }, }, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index fcd38604..9f7c8ba8 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -146,6 +146,7 @@ export const agentsApi = { ), pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), + approve: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/approve"), {}), terminate: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/terminate"), {}), remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)), listKeys: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/keys")), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index dcf9bf11..17437bc8 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -6,6 +6,7 @@ import type { CompanySecret, EnvBinding, } from "@paperclipai/shared"; +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS } from "@paperclipai/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; import { secretsApi } from "../api/secrets"; @@ -921,7 +922,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { value={eff( "heartbeat", "maxConcurrentRuns", - Number(heartbeat.maxConcurrentRuns ?? 1), + Number(heartbeat.maxConcurrentRuns ?? AGENT_DEFAULT_MAX_CONCURRENT_RUNS), )} onCommit={(v) => mark("heartbeat", "maxConcurrentRuns", v)} immediate diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index 6c4083b8..1e401eda 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -21,6 +21,7 @@ import { } from "../lib/inbox"; const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; +const INBOX_BADGE_ISSUE_LIMIT = 500; const INBOX_BADGE_HEARTBEAT_RUN_LIMIT = 200; export function useDismissedInboxAlerts() { @@ -180,6 +181,7 @@ export function useInboxBadge(companyId: string | null | undefined) { touchedByUserId: "me", inboxArchivedByUserId: "me", status: INBOX_ISSUE_STATUSES, + limit: INBOX_BADGE_ISSUE_LIMIT, }), enabled: !!companyId, }); diff --git a/ui/src/lib/new-agent-runtime-config.test.ts b/ui/src/lib/new-agent-runtime-config.test.ts index d4526471..7c38437e 100644 --- a/ui/src/lib/new-agent-runtime-config.test.ts +++ b/ui/src/lib/new-agent-runtime-config.test.ts @@ -10,7 +10,7 @@ describe("buildNewAgentRuntimeConfig", () => { intervalSec: 300, wakeOnDemand: true, cooldownSec: 10, - maxConcurrentRuns: 1, + maxConcurrentRuns: 5, }, }); }); @@ -27,7 +27,7 @@ describe("buildNewAgentRuntimeConfig", () => { intervalSec: 3600, wakeOnDemand: true, cooldownSec: 10, - maxConcurrentRuns: 1, + maxConcurrentRuns: 5, }, }); }); diff --git a/ui/src/lib/new-agent-runtime-config.ts b/ui/src/lib/new-agent-runtime-config.ts index 2de094b3..2f2e452a 100644 --- a/ui/src/lib/new-agent-runtime-config.ts +++ b/ui/src/lib/new-agent-runtime-config.ts @@ -1,3 +1,4 @@ +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS } from "@paperclipai/shared"; import { defaultCreateValues } from "../components/agent-config-defaults"; export function buildNewAgentRuntimeConfig(input?: { @@ -10,7 +11,7 @@ export function buildNewAgentRuntimeConfig(input?: { intervalSec: input?.intervalSec ?? defaultCreateValues.intervalSec, wakeOnDemand: true, cooldownSec: 10, - maxConcurrentRuns: 1, + maxConcurrentRuns: AGENT_DEFAULT_MAX_CONCURRENT_RUNS, }, }; } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2777cb07..378636e9 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -762,12 +762,13 @@ export function AgentDetail() { }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]); const agentAction = useMutation({ - mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => { + mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => { if (!agentLookupRef) return Promise.reject(new Error("No agent reference")); switch (action) { case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined); case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined); case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined); + case "approve": return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined); case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined); } }, @@ -1021,9 +1022,18 @@ export function AgentDetail() { {actionError &&

{actionError}

} {isPendingApproval && ( -

- This agent is pending board approval and cannot be invoked yet. -

+
+ This agent is pending board approval and cannot be invoked yet. + +
)} {/* Floating Save/Cancel (desktop) */} diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index f58d9688..33827634 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -93,6 +93,7 @@ import { } from "lucide-react"; const INBOX_HEARTBEAT_RUN_LIMIT = 200; +const INBOX_ISSUE_LIST_LIMIT = 500; import { Input } from "@/components/ui/input"; import { PageTabBar } from "../components/PageTabBar"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; @@ -714,9 +715,9 @@ export function Inbox() { const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId - ? queryKeys.executionWorkspaces.list(selectedCompanyId) + ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) : ["execution-workspaces", "__disabled__"], - queryFn: () => executionWorkspacesApi.list(selectedCompanyId!), + queryFn: () => executionWorkspacesApi.listSummaries(selectedCompanyId!), enabled: !!selectedCompanyId && isolatedWorkspacesEnabled, }); @@ -776,7 +777,11 @@ export function Inbox() { const { data: issues, isLoading: isIssuesLoading } = useQuery({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "with-routine-executions"], - queryFn: () => issuesApi.list(selectedCompanyId!, { includeRoutineExecutions: true }), + queryFn: () => + issuesApi.list(selectedCompanyId!, { + includeRoutineExecutions: true, + limit: INBOX_ISSUE_LIST_LIMIT, + }), enabled: !!selectedCompanyId, }); const { @@ -790,6 +795,7 @@ export function Inbox() { inboxArchivedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, includeRoutineExecutions: true, + limit: INBOX_ISSUE_LIST_LIMIT, }), enabled: !!selectedCompanyId, }); @@ -803,6 +809,7 @@ export function Inbox() { touchedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, includeRoutineExecutions: true, + limit: INBOX_ISSUE_LIST_LIMIT, }), enabled: !!selectedCompanyId, }); @@ -1937,7 +1944,7 @@ export function Inbox() { enableRoutineVisibilityFilter buttonVariant="outline" iconOnly - workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace" && w.status === "active").map((w) => ({ id: w.id, name: w.name })) : undefined} + workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined} />