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:
@@ -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(() => {});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user