diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index c4506b9e6..195dcb992 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -250,6 +250,37 @@ describe("automation ingress enable gating", () => { return { service, projectConfig }; } + it("allows manually running a disabled automation", async () => { + const rule = normalizeRuntimeRule({ + id: "manual-disabled", + name: "Manual disabled", + enabled: false, + mode: "review", + triggers: [{ type: "manual" }], + trigger: { type: "manual" }, + execution: { kind: "built-in", builtIn: { actions: [] } }, + executor: { mode: "automation-bot" }, + reviewProfile: "quick", + toolPalette: [], + contextSources: [], + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:manual-disabled", + actions: [], + }); + const { service } = createServiceForRule(rule); + + try { + await expect(service.triggerManually({ id: rule.id })).resolves.toMatchObject({ + automationId: rule.id, + status: "succeeded", + }); + } finally { + service.dispose(); + } + }); + it("blocks Linear rules without event ingress and allows them once the capability is connected", () => { const rule = normalizeRuntimeRule({ id: "linear-label", @@ -770,6 +801,214 @@ describe("automationService integration", () => { } }); + it("uses the current rule config when a schedule callback fires after a config reload", async () => { + const { db } = createInMemoryAdeDb(); + const callbacks: Array<(scheduledAt?: Date | string) => void> = []; + const stops: Array> = []; + let rule = normalizeRuntimeRule({ + id: "release-ade", + name: "Release ADE", + enabled: true, + mode: "review", + triggers: [{ type: "schedule", cron: "0 9 * * 1-5" }], + trigger: { type: "schedule", cron: "0 9 * * 1-5" }, + execution: { kind: "agent-session", session: {} }, + executor: { mode: "automation-bot" }, + prompt: "Run the release.", + modelConfig: { modelId: "anthropic/claude-opus-5" }, + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:release-ade", + actions: [], + }); + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + local: { automations: [rule] }, + effective: { automations: [rule], providerMode: "guest" }, + }), + } as any; + const laneService = { + list: vi.fn(async () => [{ + id: "lane-primary", + name: "Main", + laneType: "primary", + branchRef: "main", + worktreePath: "/tmp", + }]), + getLaneWorktreePath: vi.fn(() => "/tmp"), + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "main", + worktreePath: "/tmp", + })), + } as any; + const agentChatService = { + createSession: vi.fn(async (args: any) => ({ + id: "release-chat", + laneId: args.laneId, + })), + runSessionTurn: vi.fn(async () => ({ outputText: "Release complete." })), + } as any; + const service = createAutomationService({ + db: db as any, + logger: createLogger(), + projectId: "proj", + projectRoot: "/tmp", + laneService, + projectConfigService, + agentChatService, + cronScheduler: { + validate: vi.fn(() => true), + schedule: vi.fn((_expression: string, callback: (scheduledAt?: Date | string) => void) => { + callbacks.push(callback); + const stop = vi.fn(); + stops.push(stop); + return { stop }; + }), + }, + }); + + try { + rule = normalizeRuntimeRule({ + ...rule, + modelConfig: { modelId: "openai/gpt-5.6-luna", thinkingLevel: "xhigh" }, + }); + service.reloadFromConfig(); + callbacks[0]!(new Date("2026-07-30T13:00:00.000Z")); + + await vi.waitFor(() => { + expect(agentChatService.createSession).toHaveBeenCalledTimes(1); + }); + expect(agentChatService.createSession).toHaveBeenCalledWith(expect.objectContaining({ + provider: "codex", + model: "gpt-5.6-luna", + modelId: "openai/gpt-5.6-luna", + reasoningEffort: "xhigh", + })); + expect(agentChatService.runSessionTurn).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "release-chat", + })); + + rule = normalizeRuntimeRule({ + ...rule, + triggers: [{ type: "schedule", cron: "0 10 * * 1-5" }], + trigger: { type: "schedule", cron: "0 10 * * 1-5" }, + }); + service.reloadFromConfig(); + expect(stops[0]).toHaveBeenCalledTimes(1); + expect(callbacks).toHaveLength(2); + + callbacks[0]!(new Date("2026-07-30T13:01:00.000Z")); + expect(agentChatService.createSession).toHaveBeenCalledTimes(1); + callbacks[1]!(new Date("2026-07-30T13:01:00.000Z")); + await vi.waitFor(() => { + expect(agentChatService.createSession).toHaveBeenCalledTimes(2); + }); + } finally { + service.dispose(); + } + }); + + it("skips a queued scheduled occurrence after config reload", async () => { + const { db } = createInMemoryAdeDb(); + const callbacks: Array<(scheduledAt?: Date | string) => void> = []; + let releaseFirstRun!: (result: { outputText: string }) => void; + const firstRun = new Promise<{ outputText: string }>((resolve) => { + releaseFirstRun = resolve; + }); + let rule = normalizeRuntimeRule({ + id: "queued-reload", + name: "Queued reload", + enabled: true, + mode: "review", + triggers: [{ type: "schedule", cron: "0 9 * * 1-5" }], + trigger: { type: "schedule", cron: "0 9 * * 1-5" }, + execution: { kind: "agent-session", session: {} }, + executor: { mode: "automation-bot" }, + prompt: "Run the queued job.", + modelConfig: { modelId: "openai/gpt-5.6-luna" }, + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:queued-reload", + actions: [], + }); + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + local: { automations: [rule] }, + effective: { automations: [rule], providerMode: "guest" }, + }), + } as any; + const laneService = { + list: vi.fn(async () => [{ + id: "lane-primary", + name: "Main", + laneType: "primary", + branchRef: "main", + worktreePath: "/tmp", + }]), + getLaneWorktreePath: vi.fn(() => "/tmp"), + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "main", + worktreePath: "/tmp", + })), + } as any; + const agentChatService = { + createSession: vi.fn(async () => ({ id: "queued-chat", laneId: "lane-primary" })), + runSessionTurn: vi.fn(() => firstRun), + } as any; + const logger = createLogger(); + logger.info = vi.fn(); + const service = createAutomationService({ + db: db as any, + logger, + projectId: "proj", + projectRoot: "/tmp", + laneService, + projectConfigService, + agentChatService, + cronScheduler: { + validate: vi.fn(() => true), + schedule: vi.fn((_expression: string, callback: (scheduledAt?: Date | string) => void) => { + callbacks.push(callback); + return { stop: vi.fn() }; + }), + }, + }); + + try { + callbacks[0]!(new Date("2026-07-30T13:00:00.000Z")); + await vi.waitFor(() => { + expect(agentChatService.createSession).toHaveBeenCalledTimes(1); + }); + + callbacks[0]!(new Date("2026-07-30T13:01:00.000Z")); + rule = normalizeRuntimeRule({ ...rule, enabled: false }); + service.reloadFromConfig(); + releaseFirstRun({ outputText: "First run complete." }); + + await vi.waitFor(() => { + expect(logger.info).toHaveBeenCalledWith( + "automations.trigger.suppressed", + expect.objectContaining({ automationId: "queued-reload", triggerType: "schedule", reason: "disabled" }), + ); + }); + expect(agentChatService.createSession).toHaveBeenCalledTimes(1); + } finally { + service.dispose(); + } + }); + it("preserves cancellation when a deleted automation chat rejects its active turn", async () => { const { db, raw } = createInMemoryAdeDb(); const callbacks: Array<(scheduledAt?: Date | string) => void> = []; diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 8f3e58926..fa6f2cd1e 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -63,6 +63,7 @@ import { resolveTailscaleCliPath } from "../sync/resolveTailscaleCliPath"; const execFileAsync = promisify(execFile); type CronTask = { + cronExpression: string; stop: () => void; }; @@ -263,6 +264,9 @@ export type TriggerContext = { commitSha?: string; reason?: string; scheduledAt?: string; + /** Internal schedule identity used to validate queued callbacks after reload. */ + scheduleTriggerIndex?: number; + scheduleCronExpression?: string; reviewProfileOverride?: AutomationRule["reviewProfile"] | null; verboseTrace?: boolean; ingressEventId?: string; @@ -1134,7 +1138,7 @@ export function createAutomationService({ catch { return undefined; } }; - const runQueuesByAutomationId = new Map>(); + const runQueuesByAutomationId = new Map>(); const scheduleTasks = new Map(); const fileWatchers = new Map(); const fileChangeDebounceTimers = new Map>(); @@ -3240,11 +3244,33 @@ export function createAutomationService({ rule: AutomationRule, trigger: TriggerContext, options: { dryRun?: boolean } = {}, - ): Promise => { + ): Promise => { const previous = runQueuesByAutomationId.get(rule.id) ?? Promise.resolve(); const queued = previous .catch(() => undefined) - .then(() => runRuleNow(rule, trigger, options)); + .then(() => { + const isManualTrigger = trigger.triggerType === "manual"; + const currentRule = isManualTrigger ? rule : findRule(rule.id); + const currentTrigger = typeof trigger.scheduleTriggerIndex === "number" + ? currentRule?.triggers[trigger.scheduleTriggerIndex] + : undefined; + const staleSchedule = !isManualTrigger && trigger.triggerType === "schedule" && ( + !currentTrigger + || currentTrigger.type !== "schedule" + || (currentTrigger.cron ?? "").trim() !== (trigger.scheduleCronExpression ?? "").trim() + ); + if (!isManualTrigger && (!currentRule || !currentRule.enabled || staleSchedule)) { + logger.info("automations.trigger.suppressed", { + automationId: rule.id, + triggerType: trigger.triggerType, + triggerIndex: trigger.scheduleTriggerIndex ?? null, + cron: trigger.scheduleCronExpression ?? null, + reason: !currentRule ? "missing" : !currentRule.enabled ? "disabled" : "trigger-changed", + }); + return null; + } + return runRuleNow(currentRule ?? rule, trigger, options); + }); runQueuesByAutomationId.set(rule.id, queued); try { return await queued; @@ -3482,22 +3508,42 @@ export function createAutomationService({ } const key = `${rule.id}:${index}`; desired.add(key); - if (scheduleTasks.has(key)) return; + const existingTask = scheduleTasks.get(key); + if (existingTask?.cronExpression === cronExpr) return; + if (existingTask) { + try { + existingTask.stop(); + } catch { + // ignore + } + scheduleTasks.delete(key); + } + const automationId = rule.id; const task = cronScheduler.schedule(cronExpr, (scheduledAt) => { + const currentRule = findRule(automationId); + const currentTrigger = currentRule?.triggers[index]; + if ( + !currentRule + || !currentRule.enabled + || currentTrigger?.type !== "schedule" + || (currentTrigger.cron ?? "").trim() !== cronExpr + ) { + return; + } const firedAt = scheduledAt instanceof Date && Number.isFinite(scheduledAt.getTime()) ? scheduledAt : new Date(); let claim: ReturnType; try { claim = claimScheduledOccurrence({ - rule, + rule: currentRule, triggerIndex: index, cronExpression: cronExpr, firedAt, }); } catch (error) { logger.warn("automations.schedule.claim_failed", { - automationId: rule.id, + automationId, triggerIndex: index, cron: cronExpr, error: error instanceof Error ? error.message : String(error), @@ -3506,25 +3552,28 @@ export function createAutomationService({ } if (!claim) { logger.info("automations.schedule.duplicate_suppressed", { - automationId: rule.id, + automationId, triggerIndex: index, cron: cronExpr, scheduledSlot: scheduledMinuteSlot(firedAt), }); return; } - void runRule(rule, { + void runRule(currentRule, { triggerType: "schedule", scheduledAt: claim.scheduledSlot, - reason: rule.id, + reason: automationId, + scheduleTriggerIndex: index, + scheduleCronExpression: cronExpr, }).then((run) => { + if (!run) return; db.run( "update automation_schedule_occurrences set run_id = ? where occurrence_key = ?", [run.id, claim.occurrenceKey], ); }).catch(() => {}); }); - scheduleTasks.set(key, { stop: () => task.stop() }); + scheduleTasks.set(key, { cronExpression: cronExpr, stop: () => task.stop() }); }); } for (const [key, task] of scheduleTasks.entries()) { @@ -3889,7 +3938,7 @@ export function createAutomationService({ if (requiresTriggerLane(rule) && !laneId) { throw new Error(missingTriggerLaneMessage({ triggerType: "manual" })); } - return await runRule(rule, { + const run = await runRule(rule, { triggerType: "manual", laneId, reason: id, @@ -3897,6 +3946,8 @@ export function createAutomationService({ reviewProfileOverride: args.reviewProfileOverride ?? null, verboseTrace: Boolean(args.verboseTrace), }, { dryRun: Boolean(args.dryRun) }); + if (!run) throw new Error(`Automation '${id}' changed before it could run.`); + return run; }, getHistory(args: { id: string; limit?: number }): AutomationRun[] {