diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 7bd79f76..e553d0ac 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -213,6 +213,80 @@ describe("agent permission routes", () => { ); }); + it("normalizes direct agent creation to disable timer heartbeats by default", async () => { + const app = createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + intervalSec: 3600, + }, + }, + }); + + expect(res.status).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + runtimeConfig: { + heartbeat: { + enabled: false, + intervalSec: 3600, + }, + }, + }), + ); + }); + + it("normalizes hire requests to disable timer heartbeats by default", async () => { + const app = createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agent-hires`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + intervalSec: 3600, + }, + }, + }); + + expect(res.status).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + runtimeConfig: { + heartbeat: { + enabled: false, + intervalSec: 3600, + }, + }, + }), + ); + }); + it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f1c15b8d..266803ec 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -449,11 +449,25 @@ export function agentRoutes(db: Db) { function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) { const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {}; return { - enabled: parseBooleanLike(heartbeat.enabled) ?? true, + enabled: parseBooleanLike(heartbeat.enabled) ?? false, intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0), }; } + function normalizeNewAgentRuntimeConfig(runtimeConfig: unknown): Record { + const parsedRuntimeConfig = asRecord(runtimeConfig); + const normalizedRuntimeConfig = parsedRuntimeConfig ? { ...parsedRuntimeConfig } : {}; + const parsedHeartbeat = asRecord(normalizedRuntimeConfig.heartbeat); + const heartbeat = parsedHeartbeat ? { ...parsedHeartbeat } : {}; + + if (parseBooleanLike(heartbeat.enabled) == null) { + heartbeat.enabled = false; + } + + normalizedRuntimeConfig.heartbeat = heartbeat; + return normalizedRuntimeConfig; + } + function generateEd25519PrivateKeyPem(): string { const { privateKey } = generateKeyPairSync("ed25519"); return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); @@ -1308,6 +1322,7 @@ export function agentRoutes(db: Db) { const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, + runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig), }; const company = await db @@ -1474,6 +1489,7 @@ export function agentRoutes(db: Db) { const createdAgent = await svc.create(companyId, { ...createInput, adapterConfig: normalizedAdapterConfig, + runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig), status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2eda0ef5..32dc4913 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2159,7 +2159,7 @@ export function heartbeatService(db: Db) { const heartbeat = parseObject(runtimeConfig.heartbeat); return { - enabled: asBoolean(heartbeat.enabled, true), + enabled: asBoolean(heartbeat.enabled, false), intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)), wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true), maxConcurrentRuns: normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 314ae719..dd74c376 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -923,14 +923,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { mark("heartbeat", "enabled", v)} number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))} onNumberChange={(v) => mark("heartbeat", "intervalSec", v)} numberLabel="sec" numberPrefix="Run heartbeat every" numberHint={help.intervalSec} - showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} + showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)} /> { + it("defaults new agents to no timer heartbeat", () => { + expect(buildNewAgentRuntimeConfig()).toEqual({ + heartbeat: { + enabled: false, + intervalSec: 300, + wakeOnDemand: true, + cooldownSec: 10, + maxConcurrentRuns: 1, + }, + }); + }); + + it("preserves explicit heartbeat settings", () => { + expect( + buildNewAgentRuntimeConfig({ + heartbeatEnabled: true, + intervalSec: 3600, + }), + ).toEqual({ + heartbeat: { + enabled: true, + intervalSec: 3600, + wakeOnDemand: true, + cooldownSec: 10, + maxConcurrentRuns: 1, + }, + }); + }); +}); diff --git a/ui/src/lib/new-agent-runtime-config.ts b/ui/src/lib/new-agent-runtime-config.ts new file mode 100644 index 00000000..2de094b3 --- /dev/null +++ b/ui/src/lib/new-agent-runtime-config.ts @@ -0,0 +1,16 @@ +import { defaultCreateValues } from "../components/agent-config-defaults"; + +export function buildNewAgentRuntimeConfig(input?: { + heartbeatEnabled?: boolean; + intervalSec?: number; +}) { + return { + heartbeat: { + enabled: input?.heartbeatEnabled ?? defaultCreateValues.heartbeatEnabled, + intervalSec: input?.intervalSec ?? defaultCreateValues.intervalSec, + wakeOnDemand: true, + cooldownSec: 10, + maxConcurrentRuns: 1, + }, + }; +} diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index 9b1dd12c..498f8fad 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -23,6 +23,7 @@ import { getUIAdapter, listUIAdapters } from "../adapters"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { isValidAdapterType } from "../adapters/metadata"; import { ReportsToPicker } from "../components/ReportsToPicker"; +import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, @@ -175,15 +176,10 @@ export function NewAgent() { ...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}), adapterType: configValues.adapterType, adapterConfig: buildAdapterConfig(), - runtimeConfig: { - heartbeat: { - enabled: configValues.heartbeatEnabled, - intervalSec: configValues.intervalSec, - wakeOnDemand: true, - cooldownSec: 10, - maxConcurrentRuns: 1, - }, - }, + runtimeConfig: buildNewAgentRuntimeConfig({ + heartbeatEnabled: configValues.heartbeatEnabled, + intervalSec: configValues.intervalSec, + }), budgetMonthlyCents: 0, }); }