feat(HAI-061): make scheduler and triage processor read settings dynamically

- Update Scheduler to read settings dynamically from the store instead of using static config
- Make TriageProcessor poll interval dynamic so it responds to settings changes
- Update dashboard.ts wiring to pass dynamic settings through to engine components
- Add scheduler and triage processor tests for dynamic settings behavior
- Remove unused InlineCreateCard and stale test files
This commit is contained in:
Dustin Byrne
2026-03-26 00:04:50 -04:00
parent c2166723ee
commit afcd074caa
5 changed files with 275 additions and 21 deletions

View File

@@ -99,6 +99,13 @@ describe("Scheduler concurrency", () => {
makeTask({ id: "HAI-003", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
maxConcurrent: 3,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
await runSchedule(scheduler);
@@ -123,6 +130,118 @@ describe("Scheduler concurrency", () => {
});
});
describe("Scheduler dynamic settings reload", () => {
beforeEach(() => {
vi.clearAllMocks();
});
async function runSchedule(scheduler: Scheduler): Promise<void> {
(scheduler as any).running = true;
await scheduler.schedule();
}
it("reads maxConcurrent from store settings on each schedule() call", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "todo" }),
];
const store = createMockStore(tasks);
// Start with maxConcurrent: 1 — no room
store.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
const scheduler = new Scheduler(store);
await runSchedule(scheduler);
expect(store.moveTask).not.toHaveBeenCalled();
// Now bump maxConcurrent to 2 — room for HAI-002
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
await runSchedule(scheduler);
expect(store.moveTask).toHaveBeenCalledWith("HAI-002", "in-progress");
});
it("reads maxWorktrees from store settings on each schedule() call", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "in-review", worktree: "/tmp/wt" }),
makeTask({ id: "HAI-003", column: "todo" }),
];
const store = createMockStore(tasks);
// Start with maxWorktrees: 2 — no room (2 active worktrees)
store.getSettings.mockResolvedValue({
maxConcurrent: 4,
maxWorktrees: 2,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
const scheduler = new Scheduler(store);
await runSchedule(scheduler);
expect(store.moveTask).not.toHaveBeenCalled();
// Bump maxWorktrees to 3 — room for HAI-003
store.getSettings.mockResolvedValue({
maxConcurrent: 4,
maxWorktrees: 3,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
await runSchedule(scheduler);
expect(store.moveTask).toHaveBeenCalledWith("HAI-003", "in-progress");
});
it("refreshes poll interval when settings.pollIntervalMs changes", async () => {
const store = createMockStore([]);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
const scheduler = new Scheduler(store);
// Manually set running and activePollMs to simulate start()
(scheduler as any).running = true;
(scheduler as any).activePollMs = 15000;
(scheduler as any).pollInterval = setInterval(() => {}, 15000);
// First schedule — same interval, no change
await scheduler.schedule();
expect((scheduler as any).activePollMs).toBe(15000);
// Change pollIntervalMs
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 5000,
groupOverlappingFiles: false,
autoMerge: false,
});
await scheduler.schedule();
expect((scheduler as any).activePollMs).toBe(5000);
// Clean up
scheduler.stop();
});
});
describe("Scheduler file-scope overlap", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -206,6 +325,13 @@ describe("Scheduler worktree limit logging", () => {
makeTask({ id: "HAI-002", column: "in-progress" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 2,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
const scheduler = new Scheduler(store, { maxWorktrees: 2 });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
@@ -223,6 +349,13 @@ describe("Scheduler worktree limit logging", () => {
makeTask({ id: "HAI-002", column: "in-progress" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 2,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
const scheduler = new Scheduler(store, { maxWorktrees: 2 });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
@@ -250,6 +383,13 @@ describe("Scheduler worktree limit logging", () => {
];
const store = createMockStore(maxedTasks);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 2,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
const scheduler = new Scheduler(store, { maxWorktrees: 2, maxConcurrent: 2 });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});

View File

@@ -61,12 +61,22 @@ export interface SchedulerOptions {
* It respects:
* - Dependency ordering (tasks depending on others wait)
* - Concurrency limits (max N tasks in-progress at once)
*
* **Dynamic settings reload:** On every `schedule()` call the scheduler
* reads `maxConcurrent`, `maxWorktrees`, and `pollIntervalMs` from the
* persisted store settings (`store.getSettings()`). This means changes
* made via the dashboard Settings modal (`PUT /settings`) take effect on
* the very next poll cycle without an engine restart. The poll interval
* itself is also refreshed: if `pollIntervalMs` differs from the active
* timer, the `setInterval` is transparently restarted.
*/
export class Scheduler {
private running = false;
private scheduling = false;
private wasWorktreeLimited = false;
private pollInterval: ReturnType<typeof setInterval> | null = null;
/** The interval (ms) of the currently active `setInterval` timer. */
private activePollMs: number | null = null;
constructor(
private store: TaskStore,
@@ -78,10 +88,11 @@ export class Scheduler {
this.running = true;
const interval = this.options.pollIntervalMs ?? 15_000;
this.activePollMs = interval;
this.pollInterval = setInterval(() => this.schedule(), interval);
this.schedule();
console.log(
`[scheduler] Started (max concurrent: ${this.options.maxConcurrent ?? 2}, max worktrees: ${this.options.maxWorktrees ?? 4})`,
`[scheduler] Started (poll interval: ${interval}ms)`,
);
}
@@ -90,10 +101,27 @@ export class Scheduler {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
this.activePollMs = null;
}
console.log("[scheduler] Stopped");
}
/**
* If `newIntervalMs` differs from the currently active timer, restart
* the `setInterval` so the new cadence takes effect immediately.
*/
private refreshPollInterval(newIntervalMs?: number): void {
if (!this.running || !newIntervalMs) return;
if (newIntervalMs === this.activePollMs) return;
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
this.activePollMs = newIntervalMs;
this.pollInterval = setInterval(() => this.schedule(), newIntervalMs);
console.log(`[scheduler] Poll interval updated to ${newIntervalMs}ms`);
}
/**
* Delegates to the module-level {@link pathsOverlap} for testability.
*/
@@ -119,8 +147,11 @@ export class Scheduler {
try {
const tasks = await this.store.listTasks();
const settings = await this.store.getSettings();
const maxConcurrent = this.options.maxConcurrent ?? 2;
const maxWorktrees = this.options.maxWorktrees ?? 4;
const maxConcurrent = settings.maxConcurrent ?? this.options.maxConcurrent ?? 2;
const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4;
// Refresh the poll interval if the persisted setting has changed
this.refreshPollInterval(settings.pollIntervalMs);
// Count all tasks with active worktrees (in-progress or in-review with worktree set)
const activeWorktrees = tasks.filter(

View File

@@ -172,6 +172,49 @@ describe("TriageProcessor with semaphore", () => {
});
});
describe("TriageProcessor dynamic poll interval", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("refreshes poll interval when settings.pollIntervalMs changes", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: false,
});
const triage = new TriageProcessor(store, "/tmp/test");
// Simulate start state
(triage as any).running = true;
(triage as any).activePollMs = 10000;
(triage as any).pollInterval = setInterval(() => {}, 10000);
// First poll — same interval, no change
await (triage as any).poll();
expect((triage as any).activePollMs).toBe(10000);
// Change pollIntervalMs in settings
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 3000,
groupOverlappingFiles: false,
autoMerge: false,
});
await (triage as any).poll();
expect((triage as any).activePollMs).toBe(3000);
// Clean up
triage.stop();
});
});
describe("buildSpecificationPrompt", () => {
it("includes project commands when testCommand is set", () => {
const task = createMockTaskDetail();

View File

@@ -155,9 +155,21 @@ export interface TriageProcessorOptions {
onAgentText?: (taskId: string, delta: string) => void;
}
/**
* Processes tasks in the triage column by running an AI agent to generate
* a full PROMPT.md specification.
*
* **Dynamic poll interval:** On every `poll()` call the processor reads
* `pollIntervalMs` from the persisted store settings (`store.getSettings()`).
* If the value has changed since the last cycle the `setInterval` timer is
* transparently restarted, so dashboard setting changes take effect without
* an engine restart.
*/
export class TriageProcessor {
private running = false;
private pollInterval: ReturnType<typeof setInterval> | null = null;
/** The interval (ms) of the currently active `setInterval` timer. */
private activePollMs: number | null = null;
private processing = new Set<string>();
constructor(
@@ -171,6 +183,7 @@ export class TriageProcessor {
this.running = true;
const interval = this.options.pollIntervalMs ?? 10_000;
this.activePollMs = interval;
this.pollInterval = setInterval(() => this.poll(), interval);
this.poll();
console.log("[triage] Processor started");
@@ -181,14 +194,34 @@ export class TriageProcessor {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
this.activePollMs = null;
}
console.log("[triage] Processor stopped");
}
/**
* If `newIntervalMs` differs from the currently active timer, restart
* the `setInterval` so the new cadence takes effect immediately.
*/
private refreshPollInterval(newIntervalMs?: number): void {
if (!this.running || !newIntervalMs) return;
if (newIntervalMs === this.activePollMs) return;
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
this.activePollMs = newIntervalMs;
this.pollInterval = setInterval(() => this.poll(), newIntervalMs);
console.log(`[triage] Poll interval updated to ${newIntervalMs}ms`);
}
private async poll(): Promise<void> {
if (!this.running) return;
try {
const settings = await this.store.getSettings();
this.refreshPollInterval(settings.pollIntervalMs);
const tasks = await this.store.listTasks();
const triageTasks = tasks.filter(
(t) => t.column === "triage" && !this.processing.has(t.id),