Skip to content

Commit 50233bf

Browse files
committed
fix: address task queue feedback
Change-Id: I83a14ee950e087ba93bf1e6f964ba97c2e65f150 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent cd1dd58 commit 50233bf

File tree

4 files changed

+210
-46
lines changed

4 files changed

+210
-46
lines changed

src/browser/components/AIView.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
786786
/>
787787
<ReviewsBanner workspaceId={workspaceId} />
788788
<ConnectionStatusIndicator />
789+
{isQueuedAgentTask && (
790+
<div className="border-border-medium bg-background-secondary text-muted mb-2 rounded-md border px-3 py-2 text-xs">
791+
This agent task is queued and will start automatically when a parallel slot is available.
792+
</div>
793+
)}
789794
<ChatInput
790795
variant="workspace"
791796
workspaceId={workspaceId}

src/browser/components/Settings/sections/TasksSection.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,33 @@ export function TasksSection() {
145145
};
146146
}, [api, loaded, loadFailed, subagentAiDefaults, taskSettings]);
147147

148+
// Flush any pending debounced save on unmount so changes aren't lost.
149+
useEffect(() => {
150+
if (!api) return;
151+
if (!loaded) return;
152+
if (loadFailed) return;
153+
154+
return () => {
155+
if (saveTimerRef.current) {
156+
clearTimeout(saveTimerRef.current);
157+
saveTimerRef.current = null;
158+
}
159+
160+
if (savingRef.current) return;
161+
const payload = pendingSaveRef.current;
162+
if (!payload) return;
163+
164+
pendingSaveRef.current = null;
165+
savingRef.current = true;
166+
void api.config
167+
.saveConfig(payload)
168+
.catch(() => undefined)
169+
.finally(() => {
170+
savingRef.current = false;
171+
});
172+
};
173+
}, [api, loaded, loadFailed]);
174+
148175
const setMaxParallelAgentTasks = (rawValue: string) => {
149176
const parsed = Number(rawValue);
150177
setTaskSettings((prev) => normalizeTaskSettings({ ...prev, maxParallelAgentTasks: parsed }));

src/node/services/taskService.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,59 @@ describe("TaskService", () => {
10521052
expect(report.title).toBe("t");
10531053
});
10541054

1055+
test("waitForAgentReport cache is cleared by TTL cleanup", async () => {
1056+
const config = await createTestConfig(rootDir);
1057+
1058+
const projectPath = path.join(rootDir, "repo");
1059+
const parentId = "parent-111";
1060+
const childId = "child-222";
1061+
1062+
await config.saveConfig({
1063+
projects: new Map([
1064+
[
1065+
projectPath,
1066+
{
1067+
workspaces: [
1068+
{ path: path.join(projectPath, "parent"), id: parentId, name: "parent" },
1069+
{
1070+
path: path.join(projectPath, "child"),
1071+
id: childId,
1072+
name: "agent_explore_child",
1073+
parentWorkspaceId: parentId,
1074+
agentType: "explore",
1075+
taskStatus: "running",
1076+
},
1077+
],
1078+
},
1079+
],
1080+
]),
1081+
taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 },
1082+
});
1083+
1084+
const { taskService } = createTaskServiceHarness(config);
1085+
1086+
const internal = taskService as unknown as {
1087+
resolveWaiters: (taskId: string, report: { reportMarkdown: string; title?: string }) => void;
1088+
cleanupExpiredCompletedReports: (nowMs: number) => void;
1089+
};
1090+
internal.resolveWaiters(childId, { reportMarkdown: "ok", title: "t" });
1091+
1092+
await config.removeWorkspace(childId);
1093+
1094+
internal.cleanupExpiredCompletedReports(Date.now() + 2 * 60 * 60 * 1000);
1095+
1096+
let caught: unknown = null;
1097+
try {
1098+
await taskService.waitForAgentReport(childId, { timeoutMs: 10 });
1099+
} catch (error: unknown) {
1100+
caught = error;
1101+
}
1102+
expect(caught).toBeInstanceOf(Error);
1103+
if (caught instanceof Error) {
1104+
expect(caught.message).toMatch(/not found/i);
1105+
}
1106+
});
1107+
10551108
test("does not request agent_report on stream end while task has active descendants", async () => {
10561109
const config = await createTestConfig(rootDir);
10571110

src/node/services/taskService.ts

Lines changed: 125 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export interface DescendantAgentTaskInfo {
7070

7171
type AgentTaskWorkspaceEntry = WorkspaceConfigEntry & { projectPath: string };
7272

73+
const COMPLETED_REPORT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
74+
const COMPLETED_REPORT_CACHE_MAX_ENTRIES = 128;
75+
7376
interface PendingTaskWaiter {
7477
createdAt: number;
7578
resolve: (report: { reportMarkdown: string; title?: string }) => void;
@@ -155,7 +158,7 @@ export class TaskService {
155158
private readonly pendingStartWaitersByTaskId = new Map<string, PendingTaskStartWaiter[]>();
156159
private readonly completedReportsByTaskId = new Map<
157160
string,
158-
{ reportMarkdown: string; title?: string }
161+
{ reportMarkdown: string; title?: string; expiresAtMs: number }
159162
>();
160163
private readonly remindedAwaitingReport = new Set<string>();
161164

@@ -676,7 +679,11 @@ export class TaskService {
676679

677680
const cached = this.completedReportsByTaskId.get(taskId);
678681
if (cached) {
679-
return Promise.resolve(cached);
682+
const nowMs = Date.now();
683+
if (cached.expiresAtMs > nowMs) {
684+
return Promise.resolve({ reportMarkdown: cached.reportMarkdown, title: cached.title });
685+
}
686+
this.completedReportsByTaskId.delete(taskId);
680687
}
681688

682689
const timeoutMs = options?.timeoutMs ?? 10 * 60 * 1000; // 10 minutes
@@ -1074,38 +1081,58 @@ export class TaskService {
10741081
private async maybeStartQueuedTasks(): Promise<void> {
10751082
await using _lock = await this.mutex.acquire();
10761083

1077-
const config = this.config.loadConfigOrDefault();
1078-
const taskSettings: TaskSettings = config.taskSettings ?? DEFAULT_TASK_SETTINGS;
1084+
const configAtStart = this.config.loadConfigOrDefault();
1085+
const taskSettingsAtStart: TaskSettings = configAtStart.taskSettings ?? DEFAULT_TASK_SETTINGS;
10791086

1080-
const activeCount = this.countActiveAgentTasks(config);
1081-
const availableSlots = Math.max(0, taskSettings.maxParallelAgentTasks - activeCount);
1087+
const activeCount = this.countActiveAgentTasks(configAtStart);
1088+
const availableSlots = Math.max(0, taskSettingsAtStart.maxParallelAgentTasks - activeCount);
10821089
taskQueueDebug("TaskService.maybeStartQueuedTasks summary", {
10831090
activeCount,
1084-
maxParallelAgentTasks: taskSettings.maxParallelAgentTasks,
1091+
maxParallelAgentTasks: taskSettingsAtStart.maxParallelAgentTasks,
10851092
availableSlots,
10861093
});
10871094
if (availableSlots === 0) return;
10881095

1089-
const queued = this.listAgentTaskWorkspaces(config)
1090-
.filter((t) => t.taskStatus === "queued")
1096+
const queuedTaskIds = this.listAgentTaskWorkspaces(configAtStart)
1097+
.filter((t) => t.taskStatus === "queued" && typeof t.id === "string")
10911098
.sort((a, b) => {
10921099
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
10931100
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
10941101
return aTime - bTime;
1095-
});
1102+
})
1103+
.map((t) => t.id!);
10961104

10971105
taskQueueDebug("TaskService.maybeStartQueuedTasks candidates", {
1098-
queuedCount: queued.length,
1099-
queuedIds: queued.map((t) => t.id).filter((id): id is string => typeof id === "string"),
1106+
queuedCount: queuedTaskIds.length,
1107+
queuedIds: queuedTaskIds,
11001108
});
11011109

1102-
let startedCount = 0;
1103-
for (const task of queued) {
1104-
if (startedCount >= availableSlots) {
1110+
for (const taskId of queuedTaskIds) {
1111+
const config = this.config.loadConfigOrDefault();
1112+
const taskSettings: TaskSettings = config.taskSettings ?? DEFAULT_TASK_SETTINGS;
1113+
assert(
1114+
Number.isFinite(taskSettings.maxParallelAgentTasks) && taskSettings.maxParallelAgentTasks > 0,
1115+
"TaskService.maybeStartQueuedTasks: maxParallelAgentTasks must be a positive number"
1116+
);
1117+
1118+
const activeCount = this.countActiveAgentTasks(config);
1119+
if (activeCount >= taskSettings.maxParallelAgentTasks) {
11051120
break;
11061121
}
1107-
if (!task.id) continue;
1108-
const taskId = task.id;
1122+
1123+
const taskEntry = this.findWorkspaceEntry(config, taskId);
1124+
if (!taskEntry?.workspace.parentWorkspaceId) continue;
1125+
const task = taskEntry.workspace;
1126+
if (task.taskStatus !== "queued") continue;
1127+
1128+
// Defensive: tasks can begin streaming before taskStatus flips to "running".
1129+
if (this.aiService.isStreaming(taskId)) {
1130+
taskQueueDebug("TaskService.maybeStartQueuedTasks queued-but-streaming; marking running", {
1131+
taskId,
1132+
});
1133+
await this.setTaskStatus(taskId, "running");
1134+
continue;
1135+
}
11091136

11101137
assert(typeof task.name === "string" && task.name.trim().length > 0, "Task name missing");
11111138

@@ -1137,13 +1164,13 @@ export class TaskService {
11371164
}
11381165

11391166
const runtime = createRuntime(runtimeConfig, {
1140-
projectPath: task.projectPath,
1167+
projectPath: taskEntry.projectPath,
11411168
});
11421169

11431170
const workspaceName = task.name.trim();
11441171
let workspacePath =
11451172
coerceNonEmptyString(task.path) ??
1146-
runtime.getWorkspacePath(task.projectPath, workspaceName);
1173+
runtime.getWorkspacePath(taskEntry.projectPath, workspaceName);
11471174

11481175
let workspaceExists = false;
11491176
try {
@@ -1158,6 +1185,20 @@ export class TaskService {
11581185
? null
11591186
: await this.initStateManager.readInitStatus(taskId);
11601187

1188+
// Re-check capacity after awaiting IO to avoid dequeuing work (worktree creation/init) when
1189+
// another task became active in the meantime.
1190+
const latestConfig = this.config.loadConfigOrDefault();
1191+
const latestTaskSettings: TaskSettings = latestConfig.taskSettings ?? DEFAULT_TASK_SETTINGS;
1192+
const latestActiveCount = this.countActiveAgentTasks(latestConfig);
1193+
if (latestActiveCount >= latestTaskSettings.maxParallelAgentTasks) {
1194+
taskQueueDebug("TaskService.maybeStartQueuedTasks became full mid-loop", {
1195+
taskId,
1196+
activeCount: latestActiveCount,
1197+
maxParallelAgentTasks: latestTaskSettings.maxParallelAgentTasks,
1198+
});
1199+
break;
1200+
}
1201+
11611202
// Ensure the workspace exists before starting. Queued tasks should not create worktrees/directories
11621203
// until they are actually dequeued.
11631204
let trunkBranch =
@@ -1172,7 +1213,7 @@ export class TaskService {
11721213
let initLogger: InitLogger | null = null;
11731214
const getInitLogger = (): InitLogger => {
11741215
if (initLogger) return initLogger;
1175-
initLogger = this.startWorkspaceInit(taskId, task.projectPath);
1216+
initLogger = this.startWorkspaceInit(taskId, taskEntry.projectPath);
11761217
return initLogger;
11771218
};
11781219

@@ -1196,7 +1237,7 @@ export class TaskService {
11961237
const initLogger = getInitLogger();
11971238

11981239
const forkResult = await runtime.forkWorkspace({
1199-
projectPath: task.projectPath,
1240+
projectPath: taskEntry.projectPath,
12001241
sourceWorkspaceName: parentWorkspaceName,
12011242
newWorkspaceName: workspaceName,
12021243
initLogger,
@@ -1206,7 +1247,7 @@ export class TaskService {
12061247
const createResult: WorkspaceCreationResult = forkResult.success
12071248
? { success: true as const, workspacePath: forkResult.workspacePath }
12081249
: await runtime.createWorkspace({
1209-
projectPath: task.projectPath,
1250+
projectPath: taskEntry.projectPath,
12101251
branchName: workspaceName,
12111252
trunkBranch,
12121253
directoryName: workspaceName,
@@ -1258,7 +1299,7 @@ export class TaskService {
12581299
});
12591300
void runtime
12601301
.initWorkspace({
1261-
projectPath: task.projectPath,
1302+
projectPath: taskEntry.projectPath,
12621303
branchName: workspaceName,
12631304
trunkBranch,
12641305
workspacePath,
@@ -1316,7 +1357,6 @@ export class TaskService {
13161357

13171358
await this.setTaskStatus(taskId, "running");
13181359
taskQueueDebug("TaskService.maybeStartQueuedTasks started", { taskId });
1319-
startedCount += 1;
13201360
}
13211361
}
13221362

@@ -1613,8 +1653,31 @@ export class TaskService {
16131653
}
16141654
}
16151655

1656+
private cleanupExpiredCompletedReports(nowMs = Date.now()): void {
1657+
for (const [taskId, entry] of this.completedReportsByTaskId) {
1658+
if (entry.expiresAtMs <= nowMs) {
1659+
this.completedReportsByTaskId.delete(taskId);
1660+
}
1661+
}
1662+
}
1663+
1664+
private enforceCompletedReportCacheLimit(): void {
1665+
while (this.completedReportsByTaskId.size > COMPLETED_REPORT_CACHE_MAX_ENTRIES) {
1666+
const first = this.completedReportsByTaskId.keys().next();
1667+
if (first.done) break;
1668+
this.completedReportsByTaskId.delete(first.value);
1669+
}
1670+
}
1671+
16161672
private resolveWaiters(taskId: string, report: { reportMarkdown: string; title?: string }): void {
1617-
this.completedReportsByTaskId.set(taskId, report);
1673+
const nowMs = Date.now();
1674+
this.cleanupExpiredCompletedReports(nowMs);
1675+
this.completedReportsByTaskId.set(taskId, {
1676+
reportMarkdown: report.reportMarkdown,
1677+
title: report.title,
1678+
expiresAtMs: nowMs + COMPLETED_REPORT_CACHE_TTL_MS,
1679+
});
1680+
this.enforceCompletedReportCacheLimit();
16181681

16191682
const waiters = this.pendingWaitersByTaskId.get(taskId);
16201683
if (!waiters || waiters.length === 0) {
@@ -1850,32 +1913,48 @@ export class TaskService {
18501913
}
18511914

18521915
private async cleanupReportedLeafTask(workspaceId: string): Promise<void> {
1853-
const config = this.config.loadConfigOrDefault();
1854-
const entry = this.findWorkspaceEntry(config, workspaceId);
1855-
if (!entry) return;
1916+
assert(workspaceId.length > 0, "cleanupReportedLeafTask: workspaceId must be non-empty");
1917+
1918+
let currentWorkspaceId = workspaceId;
1919+
const visited = new Set<string>();
1920+
for (let depth = 0; depth < 32; depth++) {
1921+
if (visited.has(currentWorkspaceId)) {
1922+
log.error("cleanupReportedLeafTask: possible parentWorkspaceId cycle", {
1923+
workspaceId: currentWorkspaceId,
1924+
});
1925+
return;
1926+
}
1927+
visited.add(currentWorkspaceId);
18561928

1857-
const ws = entry.workspace;
1858-
if (!ws.parentWorkspaceId) return;
1859-
if (ws.taskStatus !== "reported") return;
1929+
const config = this.config.loadConfigOrDefault();
1930+
const entry = this.findWorkspaceEntry(config, currentWorkspaceId);
1931+
if (!entry) return;
18601932

1861-
const hasChildren = this.listAgentTaskWorkspaces(config).some(
1862-
(t) => t.parentWorkspaceId === workspaceId
1863-
);
1864-
if (hasChildren) {
1865-
return;
1866-
}
1933+
const ws = entry.workspace;
1934+
const parentWorkspaceId = ws.parentWorkspaceId;
1935+
if (!parentWorkspaceId) return;
1936+
if (ws.taskStatus !== "reported") return;
18671937

1868-
const removeResult = await this.workspaceService.remove(workspaceId, true);
1869-
if (!removeResult.success) {
1870-
log.error("Failed to auto-delete reported task workspace", {
1871-
workspaceId,
1872-
error: removeResult.error,
1873-
});
1874-
return;
1938+
const hasChildren = this.listAgentTaskWorkspaces(config).some(
1939+
(t) => t.parentWorkspaceId === currentWorkspaceId
1940+
);
1941+
if (hasChildren) return;
1942+
1943+
const removeResult = await this.workspaceService.remove(currentWorkspaceId, true);
1944+
if (!removeResult.success) {
1945+
log.error("Failed to auto-delete reported task workspace", {
1946+
workspaceId: currentWorkspaceId,
1947+
error: removeResult.error,
1948+
});
1949+
return;
1950+
}
1951+
1952+
currentWorkspaceId = parentWorkspaceId;
18751953
}
18761954

1877-
// Recursively attempt cleanup on parent if it's also a reported agent task.
1878-
await this.cleanupReportedLeafTask(ws.parentWorkspaceId);
1955+
log.error("cleanupReportedLeafTask: exceeded max parent traversal depth", {
1956+
workspaceId,
1957+
});
18791958
}
18801959

18811960
private findWorkspaceEntry(

0 commit comments

Comments
 (0)