diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 873381d5c3b..c4008034f91 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/shared/src/constants.ts b/packages/shared/src/constants.ts index a9f5de3d430..3afd727a72b 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 d1569b67ab8..b198ddc27c7 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 284b21b34b9..a3ebea95bc1 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -483,6 +483,7 @@ describe("agent permission routes", () => { heartbeat: { enabled: false, intervalSec: 3600, + maxConcurrentRuns: 5, }, }, }), @@ -520,6 +521,7 @@ describe("agent permission routes", () => { heartbeat: { enabled: false, intervalSec: 3600, + maxConcurrentRuns: 5, }, }, }), diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 082cecaa304..c23b593537d 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -2132,6 +2132,7 @@ describe("company portability", () => { runtimeConfig: { heartbeat: { enabled: false, + maxConcurrentRuns: 5, }, }, }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b0ba4ecfcdb..4a8e70e1e92 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, @@ -524,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; diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 131578de9cb..925685f56d7 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]); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 26638e757d0..bbc7d4b691b 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; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index e333cefcade..31a17784cca 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4,7 +4,12 @@ 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 type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared"; +import { + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, + type BillingType, + type ExecutionWorkspace, + type ExecutionWorkspaceConfig, +} from "@paperclipai/shared"; import { agents, agentRuntimeState, @@ -83,7 +88,7 @@ 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 = 1; +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"; diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index adb68db32a3..2a6d6a439d8 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/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index dcf9bf1148e..17437bc84f5 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/lib/new-agent-runtime-config.test.ts b/ui/src/lib/new-agent-runtime-config.test.ts index d4526471b2e..7c38437e278 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 2de094b3849..2f2e452a55f 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, }, }; }