feat(FN-3895): add event-driven unblock for scheduler startup recovery when
Adds an event-driven unblock path that automatically releases agents when their blocked-by dependencies complete, including startup recovery logic for any tasks still blocked at engine initialization; covered by scheduler and self-healing test suites with a minor CSS fix for the compact mobile impor Fusion-Task-Id: FN-3895
This commit is contained in:
5
.changeset/fn-3895-unblock-on-done.md
Normal file
5
.changeset/fn-3895-unblock-on-done.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Improve stale dependency unblocking so todo tasks are released promptly when their blocker reaches done or archived, and ensure startup recovery runs the stale `blockedBy` sweep once on boot to repair previously stuck rows. This complements the existing periodic self-heal pass, reducing unblock latency and automatically repairing incidents like dependents remaining blocked after a completed task.
|
||||
@@ -1306,8 +1306,9 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* New Agent: icon-only on mobile, sized to match view-toggle buttons.
|
||||
Text is visually hidden via font-size: 0 (the SVG icon keeps its size). */
|
||||
/* Import + New Agent: icon-only on mobile, sized to match view-toggle
|
||||
buttons so the no-wrap action row never overflows (FN-3895 follow-up). */
|
||||
.agents-view-primary-actions .agent-import-trigger,
|
||||
.agents-view-primary-actions .btn-task-create {
|
||||
width: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||
height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||
|
||||
@@ -800,7 +800,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
className="btn btn-sm agent-import-trigger"
|
||||
onClick={() => {
|
||||
setIsImporting(true);
|
||||
setIsControlsPanelOpen(false);
|
||||
|
||||
@@ -338,6 +338,90 @@ describe("Scheduler", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
|
||||
});
|
||||
|
||||
it.each(["done", "archived"] as const)("FN-3895: clears blockedBy when blocker moves to %s", async (to) => {
|
||||
const dependent = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-3885" });
|
||||
const blocker = createMockTask({ id: "FN-3885", column: to });
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([dependent]),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
new Scheduler(store);
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
await movedHandler({ task: blocker, from: "in-review", to });
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null });
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-3799",
|
||||
`Auto-unblocked: blocker FN-3885 reached ${to}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("FN-3895: does not clear blockedBy for non-terminal transitions", async () => {
|
||||
const dependent = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-3885" });
|
||||
const blocker = createMockTask({ id: "FN-3885", column: "in-review" });
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([dependent]),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
});
|
||||
|
||||
new Scheduler(store);
|
||||
const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
await movedHandler({ task: blocker, from: "in-progress", to: "in-review" });
|
||||
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null });
|
||||
});
|
||||
|
||||
it("FN-3895: does not clear blockedBy for tasks blocked by a different task", async () => {
|
||||
const dependent = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-4000" });
|
||||
const blocker = createMockTask({ id: "FN-3885", column: "done" });
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([dependent]),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
});
|
||||
|
||||
new Scheduler(store);
|
||||
const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
await movedHandler({ task: blocker, from: "in-review", to: "done" });
|
||||
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null });
|
||||
});
|
||||
|
||||
it("FN-3895: skips event-driven unblock when enginePaused is true", async () => {
|
||||
const dependent = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-3885" });
|
||||
const blocker = createMockTask({ id: "FN-3885", column: "done" });
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([dependent]),
|
||||
getSettings: vi.fn().mockResolvedValue({ enginePaused: true, globalPause: false }),
|
||||
});
|
||||
|
||||
new Scheduler(store);
|
||||
const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
await movedHandler({ task: blocker, from: "in-review", to: "done" });
|
||||
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null });
|
||||
});
|
||||
|
||||
it("FN-3895: unblocks FN-3799 and FN-3811 once FN-3885 reaches done", async () => {
|
||||
const dependentA = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-3885" });
|
||||
const dependentB = createMockTask({ id: "FN-3811", column: "todo", blockedBy: "FN-3885" });
|
||||
const blocker = createMockTask({ id: "FN-3885", column: "done" });
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([dependentA, dependentB]),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
});
|
||||
|
||||
new Scheduler(store);
|
||||
const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
await movedHandler({ task: blocker, from: "in-review", to: "done" });
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3811", { blockedBy: null, status: null });
|
||||
});
|
||||
|
||||
it("does not trigger scheduling for non-done task:moved events", async () => {
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([
|
||||
|
||||
@@ -440,6 +440,7 @@ describe("SelfHealingManager", () => {
|
||||
const recoverOrphanedExecutions = vi.spyOn(manager, "recoverOrphanedExecutions").mockResolvedValue(1);
|
||||
const recoverApprovedTriageTasks = vi.spyOn(manager, "recoverApprovedTriageTasks").mockResolvedValue(1);
|
||||
const recoverOrphanedAgents = vi.spyOn(manager, "recoverOrphanedAgents").mockResolvedValue(1);
|
||||
const clearStaleBlockedBy = vi.spyOn(manager, "clearStaleBlockedBy").mockResolvedValue(1);
|
||||
|
||||
await manager.runStartupRecovery();
|
||||
|
||||
@@ -451,6 +452,23 @@ describe("SelfHealingManager", () => {
|
||||
expect(recoverOrphanedExecutions).toHaveBeenCalledTimes(1);
|
||||
expect(recoverApprovedTriageTasks).toHaveBeenCalledTimes(1);
|
||||
expect(recoverOrphanedAgents).toHaveBeenCalledTimes(1);
|
||||
expect(clearStaleBlockedBy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runStartupRecovery clears stale blockedBy rows", async () => {
|
||||
vi.mocked(store.getSettings).mockResolvedValue({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
} as unknown as Settings);
|
||||
vi.mocked(store.listTasks).mockResolvedValue([
|
||||
{ id: "A", column: "todo", blockedBy: "B", paused: false, mergeRetries: 0 } as unknown as Task,
|
||||
{ id: "B", column: "done", blockedBy: null, paused: false, mergeRetries: 0 } as unknown as Task,
|
||||
]);
|
||||
|
||||
await manager.runStartupRecovery();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("A", { blockedBy: null, status: null });
|
||||
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("Auto-recovered: cleared stale blockedBy"));
|
||||
});
|
||||
|
||||
it("runStartupRecovery skips while enginePaused is active", async () => {
|
||||
@@ -464,6 +482,21 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
expect(recoverCompletedTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runStartupRecovery skips while globalPause is active", async () => {
|
||||
vi.mocked(store.getSettings).mockResolvedValue({
|
||||
globalPause: true,
|
||||
enginePaused: false,
|
||||
} as unknown as Settings);
|
||||
vi.mocked(store.listTasks).mockResolvedValue([
|
||||
{ id: "A", column: "todo", blockedBy: "B", paused: false, mergeRetries: 0 } as unknown as Task,
|
||||
{ id: "B", column: "done", blockedBy: null, paused: false, mergeRetries: 0 } as unknown as Task,
|
||||
]);
|
||||
|
||||
await manager.runStartupRecovery();
|
||||
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("A", { blockedBy: null, status: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverOrphanedAgents", () => {
|
||||
|
||||
@@ -232,7 +232,7 @@ export class Scheduler {
|
||||
* Also handles mission auto-advance: when a linked task completes,
|
||||
* update feature status and potentially activate next pending slice.
|
||||
*/
|
||||
this.store.on("task:moved", ({ task, from, to }) => {
|
||||
this.store.on("task:moved", async ({ task, from, to }) => {
|
||||
// PR Monitoring
|
||||
if (this.options.prMonitor) {
|
||||
if (to === "in-review" && task.prInfo) {
|
||||
@@ -279,6 +279,34 @@ export class Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// FN-3895: complement periodic stale-blockedBy self-healing with immediate
|
||||
// unblock when a blocker reaches a terminal completion column.
|
||||
if (to === "done" || to === "archived") {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (!settings.globalPause && !settings.enginePaused) {
|
||||
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
|
||||
for (const dependent of todoTasks) {
|
||||
if (dependent.blockedBy !== task.id) continue;
|
||||
try {
|
||||
await this.store.updateTask(dependent.id, { blockedBy: null, status: null });
|
||||
await this.store.logEntry(
|
||||
dependent.id,
|
||||
`Auto-unblocked: blocker ${task.id} reached ${to}`,
|
||||
);
|
||||
} catch (error) {
|
||||
schedulerLog.error(
|
||||
`Failed to auto-unblock dependent ${dependent.id} for blocker ${task.id}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
schedulerLog.error(`Failed event-driven unblock pass for blocker ${task.id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Event-driven scheduling: when a task moves to "done" (completion) or "todo" (retry/manual move),
|
||||
// trigger scheduling immediately so waiting tasks can start without waiting
|
||||
// for the next poll interval (up to 15 seconds).
|
||||
|
||||
@@ -240,6 +240,7 @@ export class SelfHealingManager {
|
||||
{ name: "orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks().then(() => undefined) },
|
||||
{ name: "recover-orphaned-agents", fn: () => this.recoverOrphanedAgents().then(() => undefined) },
|
||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().then(() => undefined) },
|
||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
|
||||
Reference in New Issue
Block a user