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:
Fusion
2026-05-09 18:58:13 -07:00
committed by gsxdsm
parent 4ecdbf49d3
commit ff9fb551e3
7 changed files with 156 additions and 4 deletions

View 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.

View File

@@ -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));

View File

@@ -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);

View File

@@ -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([

View File

@@ -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", () => {

View File

@@ -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).

View File

@@ -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) {