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;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* New Agent: icon-only on mobile, sized to match view-toggle buttons.
|
/* Import + New Agent: icon-only on mobile, sized to match view-toggle
|
||||||
Text is visually hidden via font-size: 0 (the SVG icon keeps its size). */
|
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 {
|
.agents-view-primary-actions .btn-task-create {
|
||||||
width: calc(var(--space-lg) * 2 + var(--space-xs));
|
width: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
height: 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} />
|
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm agent-import-trigger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
setIsControlsPanelOpen(false);
|
setIsControlsPanelOpen(false);
|
||||||
|
|||||||
@@ -338,6 +338,90 @@ describe("Scheduler", () => {
|
|||||||
expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
|
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 () => {
|
it("does not trigger scheduling for non-done task:moved events", async () => {
|
||||||
const store = createMockStore({
|
const store = createMockStore({
|
||||||
listTasks: vi.fn().mockResolvedValue([
|
listTasks: vi.fn().mockResolvedValue([
|
||||||
|
|||||||
@@ -440,6 +440,7 @@ describe("SelfHealingManager", () => {
|
|||||||
const recoverOrphanedExecutions = vi.spyOn(manager, "recoverOrphanedExecutions").mockResolvedValue(1);
|
const recoverOrphanedExecutions = vi.spyOn(manager, "recoverOrphanedExecutions").mockResolvedValue(1);
|
||||||
const recoverApprovedTriageTasks = vi.spyOn(manager, "recoverApprovedTriageTasks").mockResolvedValue(1);
|
const recoverApprovedTriageTasks = vi.spyOn(manager, "recoverApprovedTriageTasks").mockResolvedValue(1);
|
||||||
const recoverOrphanedAgents = vi.spyOn(manager, "recoverOrphanedAgents").mockResolvedValue(1);
|
const recoverOrphanedAgents = vi.spyOn(manager, "recoverOrphanedAgents").mockResolvedValue(1);
|
||||||
|
const clearStaleBlockedBy = vi.spyOn(manager, "clearStaleBlockedBy").mockResolvedValue(1);
|
||||||
|
|
||||||
await manager.runStartupRecovery();
|
await manager.runStartupRecovery();
|
||||||
|
|
||||||
@@ -451,6 +452,23 @@ describe("SelfHealingManager", () => {
|
|||||||
expect(recoverOrphanedExecutions).toHaveBeenCalledTimes(1);
|
expect(recoverOrphanedExecutions).toHaveBeenCalledTimes(1);
|
||||||
expect(recoverApprovedTriageTasks).toHaveBeenCalledTimes(1);
|
expect(recoverApprovedTriageTasks).toHaveBeenCalledTimes(1);
|
||||||
expect(recoverOrphanedAgents).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 () => {
|
it("runStartupRecovery skips while enginePaused is active", async () => {
|
||||||
@@ -464,6 +482,21 @@ describe("SelfHealingManager", () => {
|
|||||||
|
|
||||||
expect(recoverCompletedTasks).not.toHaveBeenCalled();
|
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", () => {
|
describe("recoverOrphanedAgents", () => {
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export class Scheduler {
|
|||||||
* Also handles mission auto-advance: when a linked task completes,
|
* Also handles mission auto-advance: when a linked task completes,
|
||||||
* update feature status and potentially activate next pending slice.
|
* 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
|
// PR Monitoring
|
||||||
if (this.options.prMonitor) {
|
if (this.options.prMonitor) {
|
||||||
if (to === "in-review" && task.prInfo) {
|
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),
|
// 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
|
// trigger scheduling immediately so waiting tasks can start without waiting
|
||||||
// for the next poll interval (up to 15 seconds).
|
// 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: "orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks().then(() => undefined) },
|
||||||
{ name: "recover-orphaned-agents", fn: () => this.recoverOrphanedAgents().then(() => undefined) },
|
{ name: "recover-orphaned-agents", fn: () => this.recoverOrphanedAgents().then(() => undefined) },
|
||||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().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) {
|
for (const step of steps) {
|
||||||
|
|||||||
Reference in New Issue
Block a user