Skip to content

Commit 0daf2b2

Browse files
committed
fix(agent): make project lane scheduling fair
1 parent 4b0b95f commit 0daf2b2

5 files changed

Lines changed: 224 additions & 35 deletions

File tree

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ Short-lived in-memory caches exist only for dedupe/throttling repeated identical
102102

103103
## Agent Pipeline
104104

105-
The coordinator (`packages/agent/src/coordinator.ts`) uses a dual-trigger model: it polls via `node-cron` every 30 seconds as a fallback and also reacts to real-time events from the API WebSocket (task creation, moves, and explicit `agent:wake` signals). Duplicate wakes are debounced. If the WebSocket is unavailable, the coordinator falls back to polling-only mode.
105+
The coordinator (`packages/agent/src/coordinator.ts`) uses a dual-trigger model: it polls via `node-cron` every 30 seconds as a fallback and also reacts to real-time events from the API WebSocket (task creation, moves, and explicit `agent:wake` signals). Duplicate wakes are debounced, and both trigger sources share a single-flight poll loop; a trigger received during an active cycle requests one coalesced follow-up cycle. If the WebSocket is unavailable, the coordinator falls back to polling-only mode.
106106

107-
The coordinator supports **parallel task execution** (experimental, per-project). It first selects up to `COORDINATOR_MAX_CONCURRENT_PROJECTS` (default 4) independent project lanes and runs those lanes concurrently, while `COORDINATOR_MAX_CONCURRENT_TASKS` (default 12) remains a global safety ceiling across all lanes. Within one lane, pipeline stages still drain sequentially to preserve project-local ordering. When a project has "Parallel Execution" enabled in settings, up to `COORDINATOR_MAX_CONCURRENT_TASKS_PER_PROJECT` (default 3) tasks per stage run concurrently via `Promise.allSettled`; non-parallel projects always process 1 task at a time. Tasks are atomically claimed (`lockedBy`/`lockedUntil` columns) with lock duration tied to the stage timeout; heartbeats renew the lock periodically. Stale claims (expired TTL or dead heartbeat) are auto-released. On shutdown, active locks are released immediately.
107+
The coordinator supports **parallel task execution** (experimental, per-project). It first selects up to `COORDINATOR_MAX_CONCURRENT_PROJECTS` (default 4) independent project lanes and runs those lanes concurrently, while `COORDINATOR_MAX_CONCURRENT_TASKS` (default 12) remains a global safety ceiling across all lanes. A FIFO permit governor distributes global capacity across runnable lanes and waits for released slots instead of dropping later selected lanes. Within one lane, pipeline stages still drain sequentially to preserve project-local ordering. When a project has "Parallel Execution" enabled in settings, up to `COORDINATOR_MAX_CONCURRENT_TASKS_PER_PROJECT` (default 3) tasks per stage run concurrently via `Promise.allSettled`; non-parallel projects always process 1 task at a time. Tasks are atomically claimed (`lockedBy`/`lockedUntil` columns) with lock duration tied to the stage timeout; heartbeats renew the lock periodically. Stale claims (expired TTL or dead heartbeat) are auto-released. On shutdown, active locks are released immediately.
108108

109109
It delegates workflow stages to `.claude/agents/` definitions, but actual execution transport/model/session behavior is adapter-owned through `@aif/runtime`:
110110

docs/configuration.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,11 @@ COORDINATOR_MAX_CONCURRENT_TASKS_PER_PROJECT=3
423423
COORDINATOR_MAX_CONCURRENT_PROJECTS=4
424424
```
425425

426+
> **Upgrading from the previous coordinator limit:** `COORDINATOR_MAX_CONCURRENT_TASKS`
427+
> used to control per-project/stage concurrency. It is now the global ceiling across all
428+
> projects. Existing deployments should set all three values above explicitly; leaving an old
429+
> value such as `COORDINATOR_MAX_CONCURRENT_TASKS=3` limits total coordinator concurrency to 3.
430+
426431
2. Enable per-project in the web UI: open project settings and toggle **Parallel Execution**.
427432

428433
3. For projects that also use `git.create_branches=true`, opt into task worktrees:
@@ -437,7 +442,8 @@ AIF_TASK_WORKTREES_ENABLED=true
437442
- **Parallel off** (default): 1 task per project at a time — identical to serial behavior
438443
- **Parallel on**: up to `COORDINATOR_MAX_CONCURRENT_TASKS_PER_PROJECT` tasks per project per stage
439444
- The coordinator processes up to `COORDINATOR_MAX_CONCURRENT_PROJECTS` independent project lanes concurrently in a poll cycle. Stage ordering is preserved inside a project lane, so a project's planner still drains before that same project's reviewer, but a slow planner in project A no longer blocks a reviewer in project B.
440-
- Across all lanes, `COORDINATOR_MAX_CONCURRENT_TASKS` remains the global safety ceiling for active coordinator tasks.
445+
- Across all lanes, `COORDINATOR_MAX_CONCURRENT_TASKS` remains the global safety ceiling for active coordinator tasks. Runnable lanes receive slots through a fair FIFO governor, so an older busy project cannot consume the whole first wave when other selected lanes are ready.
446+
- Cron ticks and WebSocket wakes share one single-flight poll loop. A wake received during an active cycle is coalesced into one follow-up cycle, preserving project-local stage order across trigger sources.
441447
- With `AIF_TASK_WORKTREES_ENABLED=false` (default), any branch-isolated project (`git.create_branches=true`) remains serial. The API also rejects parallel auto-queue for that combination.
442448
- With `AIF_TASK_WORKTREES_ENABLED=true`, full-mode planning for parallel branch-isolated projects creates a sibling git worktree for each task, persists its absolute path in `tasks.worktree_path`, and runs all downstream stages from that path. Legacy branch-bound tasks that have `branchName` but no `worktreePath` still force serial execution until they drain.
443449
- Tasks within a stage run concurrently via `Promise.allSettled` — a failure in one task does not block others

packages/agent/CHECKLIST.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Run through this list whenever you touch anything under `packages/agent/`.
1111
- [ ] If you added a new subagent, add tests that verify it resolves the correct agent definition and handles the runtime capability fallback.
1212
- [ ] If you touched the coordinator polling logic, verify the state machine transitions in `@aif/shared/stateMachine.ts` still line up.
1313
- [ ] Polling intervals are configured in milliseconds. Do not convert values above 59 seconds into a cron step expression.
14-
- [ ] If you touched the poll scheduler, verify periodic ticks do not overlap while a previous poll callback is still running.
14+
- [ ] If you touched the poll scheduler, verify periodic ticks and event-driven wakes share a single-flight coordinator loop; triggers received during an active cycle may request only one coalesced follow-up cycle.
1515
- [ ] If you touched first-activity watchdog logic, verify streamed runtime events (not only tool/subagent hooks) count as activity for tool-less workflows.
1616
- [ ] `npm run lint`
1717
- [ ] `npm test`

packages/agent/src/__tests__/coordinator.test.ts

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,24 @@ describe("coordinator", () => {
108108
getStageSemaphore().reset();
109109
});
110110

111+
it("should remove inactive project-stage semaphore keys", async () => {
112+
const semaphore = getStageSemaphore();
113+
114+
await semaphore.acquire("project-1:planner", 2, 2);
115+
await semaphore.acquire("project-1:planner", 2, 2);
116+
expect(semaphore.totalActive()).toBe(2);
117+
expect(semaphore.trackedKeyCount()).toBe(1);
118+
119+
semaphore.release("project-1:planner");
120+
expect(semaphore.totalActive()).toBe(1);
121+
expect(semaphore.trackedKeyCount()).toBe(1);
122+
123+
semaphore.release("project-1:planner");
124+
semaphore.release("missing:planner");
125+
expect(semaphore.totalActive()).toBe(0);
126+
expect(semaphore.trackedKeyCount()).toBe(0);
127+
});
128+
111129
function insertRuntimeProfile(input: {
112130
id: string;
113131
projectId?: string | null;
@@ -1688,9 +1706,44 @@ describe("coordinator", () => {
16881706
}
16891707
}
16901708

1691-
await pollAndProcess();
1709+
const startedProjectIds: string[] = [];
1710+
const releasePlanners: Array<() => void> = [];
1711+
let activePlanners = 0;
1712+
let peakActivePlanners = 0;
1713+
1714+
vi.mocked(runPlanner).mockImplementation((taskId) => {
1715+
const projectId = taskId.startsWith("global-cap-task-1-")
1716+
? "global-cap-project-1"
1717+
: "global-cap-project-2";
1718+
startedProjectIds.push(projectId);
1719+
activePlanners += 1;
1720+
peakActivePlanners = Math.max(peakActivePlanners, activePlanners);
1721+
1722+
if (startedProjectIds.length > 2) {
1723+
activePlanners -= 1;
1724+
return Promise.resolve();
1725+
}
1726+
1727+
return new Promise<void>((resolve) => {
1728+
releasePlanners.push(() => {
1729+
activePlanners -= 1;
1730+
resolve();
1731+
});
1732+
});
1733+
});
1734+
1735+
const pollPromise = pollAndProcess();
1736+
try {
1737+
await vi.waitFor(() => expect(releasePlanners).toHaveLength(2));
1738+
expect(new Set(startedProjectIds)).toEqual(
1739+
new Set(["global-cap-project-1", "global-cap-project-2"]),
1740+
);
1741+
expect(peakActivePlanners).toBeLessThanOrEqual(2);
1742+
} finally {
1743+
for (const release of releasePlanners) release();
1744+
await pollPromise;
1745+
}
16921746

1693-
expect(runPlanner).toHaveBeenCalledTimes(2);
16941747
expect(getStageSemaphore().totalActive()).toBe(0);
16951748
} finally {
16961749
Object.assign(coordinatorEnv, previousLimits);
@@ -1743,6 +1796,57 @@ describe("coordinator", () => {
17431796
expect(runReviewer).toHaveBeenCalledWith("ready-review-task", "/tmp/review");
17441797
});
17451798

1799+
it("should serialize overlapping poll cycles to preserve stage order within a project", async () => {
1800+
const db = testDb.current;
1801+
db.insert(projects)
1802+
.values({
1803+
id: "overlap-project",
1804+
name: "Overlap",
1805+
rootPath: "/tmp/overlap",
1806+
parallelEnabled: true,
1807+
})
1808+
.run();
1809+
db.insert(tasks)
1810+
.values({
1811+
id: "overlap-planning-task",
1812+
projectId: "overlap-project",
1813+
title: "Slow planning",
1814+
status: "planning",
1815+
})
1816+
.run();
1817+
db.insert(tasks)
1818+
.values({
1819+
id: "overlap-review-task",
1820+
projectId: "overlap-project",
1821+
title: "Ready review",
1822+
status: "review",
1823+
})
1824+
.run();
1825+
1826+
let resolvePlanner: (() => void) | undefined;
1827+
vi.mocked(runPlanner).mockImplementationOnce(
1828+
() =>
1829+
new Promise<void>((resolve) => {
1830+
resolvePlanner = resolve;
1831+
}),
1832+
);
1833+
1834+
const firstPoll = pollAndProcess();
1835+
await vi.waitFor(() => expect(resolvePlanner).toBeTypeOf("function"));
1836+
1837+
const secondPoll = pollAndProcess();
1838+
await new Promise((resolve) => setTimeout(resolve, 20));
1839+
const reviewerStartedBeforePlannerFinished = vi
1840+
.mocked(runReviewer)
1841+
.mock.calls.some(([taskId]) => taskId === "overlap-review-task");
1842+
1843+
resolvePlanner?.();
1844+
await Promise.all([firstPoll, secondPoll]);
1845+
1846+
expect(reviewerStartedBeforePlannerFinished).toBe(false);
1847+
expect(runReviewer).toHaveBeenCalledWith("overlap-review-task", "/tmp/overlap");
1848+
});
1849+
17461850
it("should start one task in each independent project lane beyond the per-project task cap", async () => {
17471851
const db = testDb.current;
17481852
for (let i = 1; i <= 4; i++) {
@@ -1759,10 +1863,26 @@ describe("coordinator", () => {
17591863
.run();
17601864
}
17611865

1762-
await pollAndProcess();
1866+
const startedTaskIds: string[] = [];
1867+
const releasePlanners: Array<() => void> = [];
1868+
vi.mocked(runPlanner).mockImplementation(
1869+
(taskId) =>
1870+
new Promise<void>((resolve) => {
1871+
startedTaskIds.push(taskId);
1872+
releasePlanners.push(resolve);
1873+
}),
1874+
);
17631875

1764-
for (let i = 1; i <= 4; i++) {
1765-
expect(runPlanner).toHaveBeenCalledWith(`lane-task-${i}`, `/tmp/lane-${i}`);
1876+
const pollPromise = pollAndProcess();
1877+
try {
1878+
await vi.waitFor(() => expect(releasePlanners).toHaveLength(4));
1879+
1880+
for (let i = 1; i <= 4; i++) {
1881+
expect(startedTaskIds).toContain(`lane-task-${i}`);
1882+
}
1883+
} finally {
1884+
for (const release of releasePlanners) release();
1885+
await pollPromise;
17661886
}
17671887
});
17681888
});

packages/agent/src/coordinator.ts

Lines changed: 89 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -127,33 +127,82 @@ const PIPELINE: StatusTransition[] = [
127127

128128
class StageSemaphore {
129129
private counts = new Map<string, number>();
130+
private activeCount = 0;
131+
private waiters: Array<{
132+
key: string;
133+
keyMax: number;
134+
globalMax: number;
135+
resolve: () => void;
136+
}> = [];
137+
138+
private canAcquire(key: string, keyMax: number, globalMax: number): boolean {
139+
const current = this.counts.get(key) ?? 0;
140+
return current < keyMax && this.activeCount < globalMax;
141+
}
130142

131143
tryAcquire(key: string, keyMax: number, globalMax: number): boolean {
132-
const current = this.counts.get(key) ?? 0;
133-
if (current >= keyMax || this.totalActive() >= globalMax) return false;
134-
this.counts.set(key, current + 1);
144+
if (!this.canAcquire(key, keyMax, globalMax)) return false;
145+
this.counts.set(key, (this.counts.get(key) ?? 0) + 1);
146+
this.activeCount += 1;
135147
return true;
136148
}
137149

150+
acquire(key: string, keyMax: number, globalMax: number): Promise<void> {
151+
if (this.tryAcquire(key, keyMax, globalMax)) {
152+
return Promise.resolve();
153+
}
154+
155+
return new Promise<void>((resolve) => {
156+
this.waiters.push({ key, keyMax, globalMax, resolve });
157+
});
158+
}
159+
138160
release(key: string): void {
139161
const current = this.counts.get(key) ?? 0;
140-
this.counts.set(key, Math.max(0, current - 1));
141-
}
162+
if (current <= 0) return;
142163

143-
available(key: string, keyMax: number, globalMax: number): number {
144-
const keyAvailable = keyMax - (this.counts.get(key) ?? 0);
145-
const globalAvailable = globalMax - this.totalActive();
146-
return Math.max(0, Math.min(keyAvailable, globalAvailable));
164+
if (current === 1) {
165+
this.counts.delete(key);
166+
} else {
167+
this.counts.set(key, current - 1);
168+
}
169+
this.activeCount -= 1;
170+
this.drainWaiters();
147171
}
148172

149173
totalActive(): number {
150-
let total = 0;
151-
for (const count of this.counts.values()) total += count;
152-
return total;
174+
return this.activeCount;
175+
}
176+
177+
trackedKeyCount(): number {
178+
return this.counts.size;
153179
}
154180

155181
reset(): void {
182+
if (this.waiters.length > 0) {
183+
throw new Error("Cannot reset stage semaphore while acquisitions are queued");
184+
}
156185
this.counts.clear();
186+
this.activeCount = 0;
187+
}
188+
189+
private drainWaiters(): void {
190+
let granted = true;
191+
while (granted) {
192+
granted = false;
193+
const waiterIndex = this.waiters.findIndex((waiter) =>
194+
this.canAcquire(waiter.key, waiter.keyMax, waiter.globalMax),
195+
);
196+
if (waiterIndex < 0) return;
197+
198+
const [waiter] = this.waiters.splice(waiterIndex, 1);
199+
if (!waiter) return;
200+
201+
this.counts.set(waiter.key, (this.counts.get(waiter.key) ?? 0) + 1);
202+
this.activeCount += 1;
203+
waiter.resolve();
204+
granted = true;
205+
}
157206
}
158207
}
159208

@@ -815,7 +864,10 @@ export function processAutoQueueAdvance(): number {
815864

816865
// ── Poll cycle ───────────────────────────────────────────────
817866

818-
export async function pollAndProcess(): Promise<void> {
867+
let activePollPromise: Promise<void> | null = null;
868+
let followUpPollRequested = false;
869+
870+
async function runPollCycle(): Promise<void> {
819871
log.debug("Starting poll cycle");
820872

821873
// Release stale locks BEFORE watchdog — otherwise watchdog moves task to blocked_external
@@ -891,13 +943,8 @@ export async function pollAndProcess(): Promise<void> {
891943
const parallel = concurrency.parallel;
892944
const projectMax = concurrency.max;
893945
const stageKey = `${projectId}:${stage.label}`;
894-
const available = stageSemaphore.available(stageKey, projectMax, globalMaxTasks);
895-
if (available <= 0) {
896-
log.debug({ stage: stage.label, projectId }, "Project stage at capacity, skipping");
897-
continue;
898-
}
899946

900-
const candidateWindow = Math.min(Math.max(available * 5, available), 50);
947+
const candidateWindow = Math.min(Math.max(projectMax * 5, projectMax), 50);
901948
const candidates = findCoordinatorTaskCandidatesForProject(
902949
projectId,
903950
stage.label,
@@ -915,7 +962,6 @@ export async function pollAndProcess(): Promise<void> {
915962
projectId,
916963
candidateCount: candidates.length,
917964
candidateWindow,
918-
available,
919965
projectMax,
920966
globalMaxTasks,
921967
},
@@ -967,17 +1013,14 @@ export async function pollAndProcess(): Promise<void> {
9671013
continue;
9681014
}
9691015

1016+
await stageSemaphore.acquire(stageKey, projectMax, globalMaxTasks);
1017+
9701018
if (!claimTask(task.id, COORDINATOR_ID, CLAIM_LOCK_DURATION_MS)) {
1019+
stageSemaphore.release(stageKey);
9711020
log.debug({ taskId: task.id, stage: stage.label }, "Task claim failed (already claimed)");
9721021
continue;
9731022
}
9741023

975-
if (!stageSemaphore.tryAcquire(stageKey, projectMax, globalMaxTasks)) {
976-
releaseTaskClaim(task.id);
977-
log.debug({ stage: stage.label, projectId }, "Project stage semaphore full after claim");
978-
break;
979-
}
980-
9811024
log.debug(
9821025
{ stage: stage.label, taskId: task.id, candidateStatus: task.status, parallel },
9831026
"Task claimed for processing",
@@ -1013,3 +1056,23 @@ export async function pollAndProcess(): Promise<void> {
10131056

10141057
log.debug("Poll cycle complete");
10151058
}
1059+
1060+
export function pollAndProcess(): Promise<void> {
1061+
if (activePollPromise) {
1062+
followUpPollRequested = true;
1063+
log.debug("Poll cycle already active; queued one follow-up cycle");
1064+
return activePollPromise;
1065+
}
1066+
1067+
async function drainPollRequests(): Promise<void> {
1068+
do {
1069+
followUpPollRequested = false;
1070+
await runPollCycle();
1071+
} while (followUpPollRequested);
1072+
}
1073+
1074+
activePollPromise = drainPollRequests().finally(() => {
1075+
activePollPromise = null;
1076+
});
1077+
return activePollPromise;
1078+
}

0 commit comments

Comments
 (0)