Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions apps/desktop/src/main/services/automations/automationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,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<ReturnType<typeof vi.fn>> = [];
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.schedule.stale_occurrence_suppressed",
expect.objectContaining({ automationId: "queued-reload", 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> = [];
Expand Down
73 changes: 62 additions & 11 deletions apps/desktop/src/main/services/automations/automationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { resolveTailscaleCliPath } from "../sync/resolveTailscaleCliPath";
const execFileAsync = promisify(execFile);

type CronTask = {
cronExpression: string;
stop: () => void;
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1134,7 +1138,7 @@ export function createAutomationService({
catch { return undefined; }
};

const runQueuesByAutomationId = new Map<string, Promise<unknown>>();
const runQueuesByAutomationId = new Map<string, Promise<AutomationRun | null>>();
const scheduleTasks = new Map<string, CronTask>();
const fileWatchers = new Map<string, FSWatcher>();
const fileChangeDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
Expand Down Expand Up @@ -3240,11 +3244,33 @@ export function createAutomationService({
rule: AutomationRule,
trigger: TriggerContext,
options: { dryRun?: boolean } = {},
): Promise<AutomationRun> => {
): Promise<AutomationRun | null> => {
const previous = runQueuesByAutomationId.get(rule.id) ?? Promise.resolve();
const queued = previous
.catch(() => undefined)
.then(() => runRuleNow(rule, trigger, options));
.then(() => {
const currentRule = findRule(rule.id);
const currentTrigger = typeof trigger.scheduleTriggerIndex === "number"
? currentRule?.triggers[trigger.scheduleTriggerIndex]
: undefined;
const staleSchedule = trigger.triggerType === "schedule" && (
!currentTrigger
|| currentTrigger.type !== "schedule"
|| (currentTrigger.cron ?? "").trim() !== (trigger.scheduleCronExpression ?? "").trim()
);
if (!currentRule || !currentRule.enabled || staleSchedule) {
if (trigger.triggerType === "schedule") {
logger.info("automations.schedule.stale_occurrence_suppressed", {
automationId: rule.id,
triggerIndex: trigger.scheduleTriggerIndex ?? null,
cron: trigger.scheduleCronExpression ?? null,
reason: !currentRule ? "missing" : !currentRule.enabled ? "disabled" : "trigger-changed",
});
}
return null;
}
return runRuleNow(currentRule, trigger, options);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
runQueuesByAutomationId.set(rule.id, queued);
try {
return await queued;
Expand Down Expand Up @@ -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<typeof claimScheduledOccurrence>;
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),
Expand All @@ -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()) {
Expand Down Expand Up @@ -3889,14 +3938,16 @@ 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,
scheduledAt: nowIso(),
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[] {
Expand Down
Loading