Skip to content

Commit ea9ac82

Browse files
committed
fix(agents): unblock dependent tasks after supersede
1 parent 910c073 commit ea9ac82

2 files changed

Lines changed: 151 additions & 18 deletions

File tree

‎src/agents/task-store.ts‎

Lines changed: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -147,31 +147,96 @@ export function isTaskClaimableBy(
147147

148148
export function areTaskDependenciesSatisfied(
149149
task: Pick<AgentTask, "depends_on">,
150-
completedTaskIds: ReadonlySet<string>,
150+
taskStates: ReadonlyMap<string, Pick<AgentTask, "status" | "replacement_task_id">>,
151151
): boolean {
152-
return task.depends_on.every((dep) => completedTaskIds.has(dep));
152+
return task.depends_on.every((dep) => isDependencyResolved(dep, taskStates));
153153
}
154154

155-
async function readCompletedTaskIds(contextDir: string): Promise<Set<string>> {
156-
const completedDir = tasksStatusDir(contextDir, "completed");
157-
let completedEntries: string[];
158-
try {
159-
completedEntries = await readdir(completedDir);
160-
} catch {
161-
completedEntries = [];
162-
}
155+
function isDependencyResolved(
156+
taskId: string,
157+
taskStates: ReadonlyMap<string, Pick<AgentTask, "status" | "replacement_task_id">>,
158+
visiting: Set<string> = new Set(),
159+
): boolean {
160+
const state = taskStates.get(taskId);
161+
if (!state) return false;
162+
if (state.status === "completed") return true;
163+
if (state.status !== "superseded") return false;
164+
if (!state.replacement_task_id) return true;
165+
if (visiting.has(taskId)) return false;
166+
visiting.add(taskId);
167+
const resolved = isDependencyResolved(state.replacement_task_id, taskStates, visiting);
168+
visiting.delete(taskId);
169+
return resolved;
170+
}
163171

164-
return new Set(
165-
completedEntries
166-
.filter((f) => f.endsWith(".json"))
167-
.map((f) => f.replace(".json", "")),
172+
async function readTaskDependencyStates(
173+
contextDir: string,
174+
): Promise<Map<string, Pick<AgentTask, "status" | "replacement_task_id">>> {
175+
const tasks = await listTasks(contextDir);
176+
return new Map(
177+
tasks.map((task) => [
178+
task.task_id,
179+
{
180+
status: task.status,
181+
replacement_task_id: task.replacement_task_id,
182+
},
183+
]),
168184
);
169185
}
170186

187+
async function unblockSatisfiedBlockedTasks(contextDir: string): Promise<number> {
188+
const blocked = await listTasks(contextDir, "blocked");
189+
if (blocked.length === 0) return 0;
190+
191+
const taskStates = await readTaskDependencyStates(contextDir);
192+
const blockedDir = tasksStatusDir(contextDir, "blocked");
193+
const pendingDir = tasksStatusDir(contextDir, "pending");
194+
const tmpDir = tasksTmp(contextDir);
195+
await mkdir(pendingDir, { recursive: true });
196+
197+
let unblocked = 0;
198+
for (const task of blocked) {
199+
if (!areTaskDependenciesSatisfied(task, taskStates)) continue;
200+
201+
const filename = `${task.task_id}.json`;
202+
const blockedPath = resolve(blockedDir, filename);
203+
const pendingPath = resolve(pendingDir, filename);
204+
const now = Date.now();
205+
task.status = "pending";
206+
task.updated_at = now;
207+
task.claimed_by = null;
208+
task.claimed_at = null;
209+
210+
try {
211+
await atomicJsonWrite(tmpDir, blockedPath, task);
212+
await rename(blockedPath, pendingPath);
213+
} catch {
214+
continue;
215+
}
216+
217+
taskStates.set(task.task_id, {
218+
status: "pending",
219+
replacement_task_id: task.replacement_task_id,
220+
});
221+
222+
await appendTaskEvent(contextDir, {
223+
ts: now,
224+
type: "task_requeued",
225+
task_id: task.task_id,
226+
title: task.title,
227+
});
228+
unblocked++;
229+
}
230+
231+
return unblocked;
232+
}
233+
171234
export async function hasActionableTaskForMember(
172235
contextDir: string,
173236
memberId: string,
174237
): Promise<boolean> {
238+
await unblockSatisfiedBlockedTasks(contextDir);
239+
175240
const inProgress = await listTasks(contextDir, "in_progress");
176241
if (inProgress.some((task) => task.claimed_by === memberId)) {
177242
return true;
@@ -183,9 +248,9 @@ export async function hasActionableTaskForMember(
183248
return false;
184249
}
185250

186-
const completedTaskIds = await readCompletedTaskIds(contextDir);
251+
const taskStates = await readTaskDependencyStates(contextDir);
187252
return claimablePending.some((task) =>
188-
areTaskDependenciesSatisfied(task, completedTaskIds),
253+
areTaskDependenciesSatisfied(task, taskStates),
189254
);
190255
}
191256

@@ -249,8 +314,8 @@ export async function claimTask(
249314

250315
// Check dependencies
251316
if (task.depends_on.length > 0) {
252-
const completedIds = await readCompletedTaskIds(contextDir);
253-
const unmet = task.depends_on.filter((dep) => !completedIds.has(dep));
317+
const taskStates = await readTaskDependencyStates(contextDir);
318+
const unmet = task.depends_on.filter((dep) => !isDependencyResolved(dep, taskStates));
254319
if (unmet.length > 0) {
255320
// Move to blocked/
256321
const blockedPath = resolve(blockedDir, filename);
@@ -394,6 +459,8 @@ export async function completeTask(
394459
by: memberId,
395460
});
396461

462+
await unblockSatisfiedBlockedTasks(contextDir);
463+
397464
return { ok: true };
398465
}
399466

@@ -467,5 +534,7 @@ export async function supersedeTask(
467534
replacement_task_id: replacementTaskId,
468535
});
469536

537+
await unblockSatisfiedBlockedTasks(contextDir);
538+
470539
return { ok: true };
471540
}

‎tests/unit/agents/task-store.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
listTasks,
1919
claimTask,
2020
completeTask,
21+
hasActionableTaskForMember,
2122
supersedeTask,
2223
validateArtifactPath,
2324
} from "../../../src/agents/task-store.js";
@@ -169,6 +170,42 @@ describe("claimTask", () => {
169170
expect(result.ok).toBe(true);
170171
});
171172

173+
it("allows claim when dependencies are superseded without replacement", async () => {
174+
const { taskId: dep } = await addTask({ contextDir, title: "Dependency", description: "" });
175+
await supersedeTask(contextDir, dep, "lead", "no longer needed");
176+
177+
const { taskId } = await addTask({
178+
contextDir,
179+
title: "Dependent Task",
180+
description: "",
181+
dependsOn: [dep],
182+
});
183+
184+
const result = await claimTask(contextDir, taskId, "lead");
185+
expect(result.ok).toBe(true);
186+
});
187+
188+
it("keeps dependencies unmet when superseded task has unresolved replacement", async () => {
189+
const { taskId: dep } = await addTask({ contextDir, title: "Dependency", description: "" });
190+
const { taskId: replacement } = await addTask({
191+
contextDir,
192+
title: "Replacement",
193+
description: "",
194+
});
195+
await supersedeTask(contextDir, dep, "lead", "moved", replacement);
196+
197+
const { taskId } = await addTask({
198+
contextDir,
199+
title: "Dependent Task",
200+
description: "",
201+
dependsOn: [dep],
202+
});
203+
204+
const result = await claimTask(contextDir, taskId, "lead");
205+
expect(result.ok).toBe(false);
206+
expect(result.error).toContain("Unmet dependencies");
207+
});
208+
172209
it("emits task_claimed event", async () => {
173210
const { taskId } = await addTask({ contextDir, title: "Claim Event", description: "" });
174211
await claimTask(contextDir, taskId, "alice");
@@ -342,4 +379,31 @@ describe("supersedeTask", () => {
342379
expect(superseded.reason).toBe("duplicate");
343380
expect(superseded.replacement_task_id).toBe("replacement-task");
344381
});
382+
383+
it("requeues blocked dependents when a dependency is superseded terminally", async () => {
384+
const { taskId: dep } = await addTask({
385+
contextDir,
386+
title: "Dependency",
387+
description: "",
388+
assignedTo: "lead",
389+
});
390+
const { taskId } = await addTask({
391+
contextDir,
392+
title: "Dependent Task",
393+
description: "",
394+
dependsOn: [dep],
395+
assignedTo: "alice",
396+
});
397+
398+
const blockedClaim = await claimTask(contextDir, taskId, "alice");
399+
expect(blockedClaim.ok).toBe(false);
400+
expect(await hasActionableTaskForMember(contextDir, "alice")).toBe(false);
401+
402+
const superseded = await supersedeTask(contextDir, dep, "lead", "obsolete");
403+
expect(superseded.ok).toBe(true);
404+
405+
const pendingEntries = await readdir(tasksStatusDir(contextDir, "pending"));
406+
expect(pendingEntries).toContain(`${taskId}.json`);
407+
expect(await hasActionableTaskForMember(contextDir, "alice")).toBe(true);
408+
});
345409
});

0 commit comments

Comments
 (0)