FN-6793: enforce dependency gates before review
Dependency gating now blocks executor and review recovery paths when dependencies remain unmet. - Re-check unmet scheduling dependencies before workflow graph or authoritative executor dispatch and requeue blocked tasks with blockedBy. - Rebound auto-merge-eligible in-review tasks with live unmet dependencies back to todo while preserving progress, worktree, and resume state. - Add run-audit documentation, a patch changeset, and regression coverage for executor, scheduler, and self-healing behavior. Files changed: .changeset/fn-6793-dependency-gating.md | 7 + AGENTS.md | 1 + docs/architecture.md | 2 + .../engine/src/__tests__/executor-core.test.ts | 68 +++++++++ .../in-review-unmet-dependency-reconcile.test.ts | 115 +++++++++++++++ packages/engine/src/__tests__/scheduler.test.ts | 36 +++++ packages/engine/src/__tests__/self-healing.test.ts | 159 +++++++++++++++++++++ packages/engine/src/executor.ts | 46 ++++++ packages/engine/src/self-healing.ts | 112 +++++++++++++++ 9 files changed, 546 insertions(+) Fusion-Task-Id: FN-6793 Fusion-Task-Lineage: b209264c-faae-41aa-a024-c33e0d8b61be
This commit is contained in:
7
.changeset/fn-6793-dependency-gating.md
Normal file
7
.changeset/fn-6793-dependency-gating.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix dependency gating so workflow-graph and workflow-authoritative executor dispatches re-check unmet task dependencies before running, requeueing blocked work with `blockedBy` instead of allowing it to advance to review.
|
||||
|
||||
Add self-healing reconciliation for already-advanced `in-review` tasks with unmet dependencies, including the `task:reconcile-in-review-unmet-dependencies` run-audit event and guarded no-action companion.
|
||||
@@ -193,6 +193,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
|
||||
- FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move.
|
||||
- FN-6736: self-healing emits `task:reclaim-phantom-executor-binding` when it proves an in-memory executor-active binding is stale, clears the binding, and requeues the in-progress task with worktree/progress preserved.
|
||||
- FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs.
|
||||
- FN-6793: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when a live execution/checkout guard blocks that backward move.
|
||||
|
||||
|
||||
## Reference docs (deeper detail)
|
||||
|
||||
@@ -628,6 +628,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
||||
- Writes are idempotent: scheduler updates `status/blockedBy` only when values change, reducing per-tick churn and audit noise.
|
||||
- Self-healing remains responsible for terminal/missing blocker cleanup (`clearStaleBlockedBy()`), while scheduler overlap stamping now focuses on stable active-overlap attribution.
|
||||
- `reconcileDependencyBlockingLeases()` (FN-6292) unwinds existing dependency/lease circular waits: when an `in-progress` holder has unmet scheduling dependencies and an unmet dependency is blocked by the holder's stale file-scope lease, self-healing gates the backward move with triple proof, moves the holder back to `todo` with progress/worktree/resume state preserved, and emits `task:reconcile-dependency-blocking-lease` (or `task:reconcile-dependency-blocking-lease-no-action` when proof fails). Engine rebounds do not set `userPaused`.
|
||||
- `reconcileInReviewUnmetDependencies()` (FN-6793) enforces the same dependency invariant after accidental review advancement: unpaused, auto-merge-eligible `in-review` tasks with live unmet dependencies move back to `todo` with `status: "queued"`, `blockedBy` set to the first unmet dependency, and worktree/progress/resume state preserved; global/engine pause, user pause, `autoMerge:false`, live execution, and checkout guards leave the task untouched with a no-action audit when applicable. Engine rebounds do not set `userPaused`.
|
||||
- `StepSessionExecutor` (`step-session-executor.ts`) — per-step sessions + parallel wave execution
|
||||
- `createTaskUpdateTool()` (`executor.ts`) emits a diagnostic warning when an agent marks step N `in-progress` while another step on the same task is already `in-progress`; the update still proceeds so operators get evidence without changing task semantics.
|
||||
- `TaskCompletion` (`task-completion.ts`) — completion gate helpers
|
||||
@@ -1087,6 +1088,7 @@ The run-audit system records every mutation performed by the engine across four
|
||||
- **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`.
|
||||
- **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution.
|
||||
- **Database / `task:reconcile-dependency-blocking-lease`** — emitted by `reconcileDependencyBlockingLeases()` (FN-6292) when self-healing rebounds an `in-progress` holder to `todo` because an unmet dependency is blocked by the holder's stale file-scope lease. Metadata includes the dependency ID, blocked-by marker, and unmet dependency list.
|
||||
- **Database / `task:reconcile-in-review-unmet-dependencies`** — emitted by `reconcileInReviewUnmetDependencies()` (FN-6793) when self-healing rebounds an `in-review` task to blocked `todo` because one or more declared dependencies are still unmet. Metadata includes `unmetDeps`, `blockedBy`, and prior review status; the `-no-action` companion is emitted when live execution or checkout evidence prevents the backward move.
|
||||
- **Database / `task:reconcile-orphaned-task-dir`** — emitted by `TaskStore.reconcileOrphanedTaskDirs()` (FN-6783) when store open or self-healing Batch 1 re-imports a valid live `.fusion/tasks/{ID}/task.json` directory with no SQLite task row anywhere. Metadata includes the recovered ID, column, status, and task JSON path.
|
||||
- **Database / `task:*-no-action` backward-move family (FN-5335)** — backward self-healing sweeps now emit annotation-only events when triple proof fails instead of mutating lifecycle state. New mutation types: `task:reclaim-pr-conflict-no-action`, `task:reclaim-self-owned-branch-conflict-no-action`, `task:auto-rebound-scope-decay-no-action`, `task:finalize-no-op-review-no-action`, `task:stale-incomplete-review-no-action`, `task:ghost-review-no-action`, `task:stuck-merge-deadlock-no-action`, `task:no-progress-no-task-done-no-action`, `task:missing-worktree-review-no-action`, `task:partial-progress-no-task-done-no-action`, `task:reconcile-dependency-blocking-lease-no-action`. See `docs/self-healing-backward-move-audit.md` for per-stage disposition.
|
||||
- **Filesystem** — file:write, prompt:write, attachment:create, etc.
|
||||
|
||||
@@ -151,6 +151,74 @@ describe("buildExecutionPrompt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor dependency dispatch gate", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
});
|
||||
|
||||
const task = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: "FN-DP",
|
||||
title: "Dependent task",
|
||||
description: "Dependent task",
|
||||
column: "in-progress",
|
||||
dependencies: ["FN-DEP"],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# Test",
|
||||
createdAt: "2026-06-20T00:00:00.000Z",
|
||||
updatedAt: "2026-06-20T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task);
|
||||
|
||||
it("requeues workflow-authoritative dispatch when a live dependency is unmet", async () => {
|
||||
const dependent = task();
|
||||
const dependency = task({ id: "FN-DEP", column: "todo", dependencies: [] });
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([dependent, dependency]);
|
||||
store.getTask.mockResolvedValue(dependent);
|
||||
const workflowAuthoritativeDispatch = vi.fn().mockResolvedValue(true);
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { workflowAuthoritativeDispatch });
|
||||
const graphDispatch = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true);
|
||||
|
||||
await executor.execute(dependent);
|
||||
|
||||
expect(graphDispatch).not.toHaveBeenCalled();
|
||||
expect(workflowAuthoritativeDispatch).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-DP", "todo", expect.objectContaining({
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
preserveResumeState: true,
|
||||
}));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-DP", { status: "queued", blockedBy: "FN-DEP" }, undefined);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-DP",
|
||||
"queued — unmet dependencies: FN-DEP",
|
||||
"Executor pre-dispatch dependency gate blocked workflow/authoritative execution.",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows workflow-authoritative dispatch when dependencies are satisfied or absent", async () => {
|
||||
const dependent = task({ dependencies: ["FN-DONE", "FN-REVIEW", "FN-ARCHIVED", "FN-MISSING"] });
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([
|
||||
dependent,
|
||||
task({ id: "FN-DONE", column: "done", dependencies: [] }),
|
||||
task({ id: "FN-REVIEW", column: "in-review", dependencies: [] }),
|
||||
task({ id: "FN-ARCHIVED", column: "archived", dependencies: [] }),
|
||||
]);
|
||||
store.getTask.mockResolvedValue(dependent);
|
||||
const workflowAuthoritativeDispatch = vi.fn().mockResolvedValue(true);
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { workflowAuthoritativeDispatch });
|
||||
|
||||
await executor.execute(dependent);
|
||||
|
||||
expect(workflowAuthoritativeDispatch).toHaveBeenCalledWith(dependent);
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-DP", expect.objectContaining({ status: "queued" }), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor review addressing transitions", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { activeSessionRegistry, executingTaskLock } from "../active-session-registry.js";
|
||||
|
||||
function task(overrides: Partial<Task>): Task {
|
||||
return {
|
||||
id: "FN-T",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-06-20T00:00:00.000Z",
|
||||
updatedAt: "2026-06-20T00:00:00.000Z",
|
||||
prompt: "",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(initialTasks: Task[], settings: Partial<Settings> = {}): { store: TaskStore & EventEmitter; tasks: Map<string, Task> } {
|
||||
const tasks = new Map(initialTasks.map((entry) => [entry.id, entry]));
|
||||
const emitter = new EventEmitter();
|
||||
const store = Object.assign(emitter, {
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, autoMerge: true, ...settings }),
|
||||
listTasks: vi.fn(async () => [...tasks.values()]),
|
||||
moveTask: vi.fn(async (taskId: string, column: Task["column"]) => {
|
||||
const current = tasks.get(taskId);
|
||||
if (!current) throw new Error(`missing ${taskId}`);
|
||||
const updated = { ...current, column } as Task;
|
||||
tasks.set(taskId, updated);
|
||||
return updated;
|
||||
}),
|
||||
updateTask: vi.fn(async (taskId: string, updates: Partial<Task>) => {
|
||||
const current = tasks.get(taskId);
|
||||
if (!current) throw new Error(`missing ${taskId}`);
|
||||
const updated = { ...current, ...updates } as Task;
|
||||
tasks.set(taskId, updated);
|
||||
return updated;
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null),
|
||||
getTask: vi.fn(async (taskId: string) => tasks.get(taskId) ?? task({ id: taskId })),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
return { store, tasks };
|
||||
}
|
||||
|
||||
describe("executor dependency dispatch gate", () => {
|
||||
afterEach(() => {
|
||||
activeSessionRegistry.clear();
|
||||
executingTaskLock._clearForTest();
|
||||
});
|
||||
|
||||
it("blocks workflow graph and authoritative dispatch before unmet dependencies can advance", async () => {
|
||||
const dependent = task({ id: "FN-DISPATCH", column: "in-progress", dependencies: ["FN-DEP"] });
|
||||
const { store } = createStore([
|
||||
dependent,
|
||||
task({ id: "FN-DEP", column: "todo" }),
|
||||
]);
|
||||
const workflowAuthoritativeDispatch = vi.fn().mockResolvedValue(true);
|
||||
const executor = new TaskExecutor(store, "/tmp/test-project", { workflowAuthoritativeDispatch });
|
||||
const graphDispatch = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true);
|
||||
|
||||
await executor.execute(dependent);
|
||||
|
||||
expect(graphDispatch).not.toHaveBeenCalled();
|
||||
expect(workflowAuthoritativeDispatch).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-DISPATCH", "todo", expect.objectContaining({
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
preserveResumeState: true,
|
||||
}));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-DISPATCH", { status: "queued", blockedBy: "FN-DEP" }, undefined);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-DISPATCH",
|
||||
"queued — unmet dependencies: FN-DEP",
|
||||
expect.stringContaining("blocked workflow/authoritative execution"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-review unmet dependency reconciliation", () => {
|
||||
afterEach(() => {
|
||||
activeSessionRegistry.clear();
|
||||
executingTaskLock._clearForTest();
|
||||
});
|
||||
|
||||
it("reproduces FN-6778/FN-6791-class review advancement and rebounds to queued todo", async () => {
|
||||
const { store, tasks } = createStore([
|
||||
task({ id: "FN-6778", column: "in-review", dependencies: ["FN-6777"] }),
|
||||
task({ id: "FN-6777", column: "todo" }),
|
||||
task({ id: "FN-6791", column: "in-review", dependencies: ["FN-6770", "FN-6771", "FN-6780"] }),
|
||||
task({ id: "FN-6770", column: "in-progress" }),
|
||||
task({ id: "FN-6771", column: "todo" }),
|
||||
task({ id: "FN-6780", column: "todo", status: "queued" }),
|
||||
]);
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
|
||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(2);
|
||||
|
||||
expect(tasks.get("FN-6778")).toMatchObject({ column: "todo", status: "queued", blockedBy: "FN-6777" });
|
||||
expect(tasks.get("FN-6791")).toMatchObject({ column: "todo", status: "queued", blockedBy: "FN-6770" });
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||
target: "FN-6791",
|
||||
metadata: expect.objectContaining({ unmetDeps: ["FN-6770", "FN-6771", "FN-6780"] }),
|
||||
}));
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
@@ -409,6 +409,42 @@ describe("getUnmetSchedulingDependencies", () => {
|
||||
|
||||
expect(getUnmetSchedulingDependencies(task, [task, dep])).toEqual([]);
|
||||
});
|
||||
|
||||
it("blocks only live unsatisfied dependency columns across dispatch surfaces", () => {
|
||||
const task = createMockTask({
|
||||
id: "FN-T",
|
||||
dependencies: [
|
||||
"FN-TODO",
|
||||
"FN-QUEUED",
|
||||
"FN-INPROGRESS",
|
||||
"FN-TRIAGE",
|
||||
"FN-DONE",
|
||||
"FN-REVIEW",
|
||||
"FN-ARCHIVED",
|
||||
"FN-SOFT-DELETED",
|
||||
"FN-MISSING",
|
||||
],
|
||||
});
|
||||
const tasks = [
|
||||
task,
|
||||
createMockTask({ id: "FN-TODO", column: "todo" }),
|
||||
createMockTask({ id: "FN-QUEUED", column: "todo", status: "queued" }),
|
||||
createMockTask({ id: "FN-INPROGRESS", column: "in-progress" }),
|
||||
createMockTask({ id: "FN-TRIAGE", column: "triage" }),
|
||||
createMockTask({ id: "FN-DONE", column: "done" }),
|
||||
createMockTask({ id: "FN-REVIEW", column: "in-review" }),
|
||||
createMockTask({ id: "FN-ARCHIVED", column: "archived" }),
|
||||
// Soft-deleted dependency records are absent from listTasks(), matching the
|
||||
// executor/scheduler shared helper's missing-id-is-not-blocking contract.
|
||||
];
|
||||
|
||||
expect(getUnmetSchedulingDependencies(task, tasks)).toEqual([
|
||||
"FN-TODO",
|
||||
"FN-QUEUED",
|
||||
"FN-INPROGRESS",
|
||||
"FN-TRIAGE",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRunnableQueuedOverlapCandidate", () => {
|
||||
|
||||
@@ -9414,4 +9414,163 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileInReviewUnmetDependencies — FN-6793", () => {
|
||||
const makeTask = (overrides: Partial<Task>): Task => ({
|
||||
id: "FN-T",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-06-20T00:00:00.000Z",
|
||||
updatedAt: "2026-06-20T00:00:00.000Z",
|
||||
prompt: "",
|
||||
...overrides,
|
||||
} as Task);
|
||||
|
||||
const setup = (initialTasks: Task[], settings: Partial<Settings> = {}) => {
|
||||
const tasks = new Map(initialTasks.map((task) => [task.id, task]));
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, autoMerge: true, ...settings } as any),
|
||||
listTasks: vi.fn(async () => [...tasks.values()]),
|
||||
moveTask: vi.fn(async (taskId: string, column: Task["column"]) => {
|
||||
const current = tasks.get(taskId);
|
||||
if (!current) throw new Error(`missing ${taskId}`);
|
||||
const updated = { ...current, column } as Task;
|
||||
tasks.set(taskId, updated);
|
||||
return updated;
|
||||
}),
|
||||
updateTask: vi.fn(async (taskId: string, updates: Partial<Task>) => {
|
||||
const current = tasks.get(taskId);
|
||||
if (!current) throw new Error(`missing ${taskId}`);
|
||||
const updated = { ...current, ...updates } as Task;
|
||||
tasks.set(taskId, updated);
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
return { store, manager, tasks };
|
||||
};
|
||||
|
||||
it("rebounds in-review tasks with live unmet dependencies and emits run-audit", async () => {
|
||||
const { store, manager, tasks } = setup([
|
||||
makeTask({ id: "FN-6778", column: "in-review", dependencies: ["FN-6777"], worktree: "/tmp/wt", branch: "fusion/fn-6778" }),
|
||||
makeTask({ id: "FN-6777", column: "todo", status: "queued" }),
|
||||
makeTask({ id: "FN-6791", column: "in-review", dependencies: ["FN-6770", "FN-6771", "FN-6780", "FN-DONE"] }),
|
||||
makeTask({ id: "FN-6770", column: "in-progress" }),
|
||||
makeTask({ id: "FN-6771", column: "todo" }),
|
||||
makeTask({ id: "FN-6780", column: "todo", status: "queued" }),
|
||||
makeTask({ id: "FN-DONE", column: "done" }),
|
||||
]);
|
||||
|
||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(2);
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-6778", "todo", expect.objectContaining({
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
preserveResumeState: true,
|
||||
moveSource: "engine",
|
||||
recoveryRehome: true,
|
||||
}));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-6778", { status: "queued", blockedBy: "FN-6777" });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-6791", { status: "queued", blockedBy: "FN-6770" });
|
||||
expect(tasks.get("FN-6778")?.column).toBe("todo");
|
||||
expect(tasks.get("FN-6778")?.blockedBy).toBe("FN-6777");
|
||||
expect(tasks.get("FN-6791")?.blockedBy).toBe("FN-6770");
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||
target: "FN-6778",
|
||||
metadata: expect.objectContaining({ unmetDeps: ["FN-6777"], blockedBy: "FN-6777" }),
|
||||
}));
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||
target: "FN-6791",
|
||||
metadata: expect.objectContaining({ unmetDeps: ["FN-6770", "FN-6771", "FN-6780"], blockedBy: "FN-6770" }),
|
||||
}));
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("leaves satisfied, archived, and missing dependencies untouched", async () => {
|
||||
const { store, manager } = setup([
|
||||
makeTask({ id: "FN-OK", column: "in-review", dependencies: ["FN-DONE", "FN-REVIEW", "FN-ARCHIVED", "FN-MISSING"] }),
|
||||
makeTask({ id: "FN-DONE", column: "done" }),
|
||||
makeTask({ id: "FN-REVIEW", column: "in-review" }),
|
||||
makeTask({ id: "FN-ARCHIVED", column: "archived" }),
|
||||
]);
|
||||
|
||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-OK", expect.objectContaining({ status: "queued" }));
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it.each([{ userPaused: true }, { paused: true }])("does not move paused in-review tasks: %o", async (pauseState) => {
|
||||
const { store, manager } = setup([
|
||||
makeTask({ id: "FN-P", column: "in-review", dependencies: ["FN-D"], ...pauseState }),
|
||||
makeTask({ id: "FN-D", column: "todo" }),
|
||||
]);
|
||||
|
||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("honors autoMerge false as terminal-until-merged", async () => {
|
||||
const { store, manager } = setup([
|
||||
makeTask({ id: "FN-AUTO", column: "in-review", dependencies: ["FN-D"] }),
|
||||
makeTask({ id: "FN-D", column: "todo" }),
|
||||
], { autoMerge: false });
|
||||
|
||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it.each([{ globalPause: true }, { enginePaused: true }])("short-circuits while paused: %o", async (pausedSettings) => {
|
||||
const { store, manager } = setup([
|
||||
makeTask({ id: "FN-PAUSED-ENGINE", column: "in-review", dependencies: ["FN-D"] }),
|
||||
makeTask({ id: "FN-D", column: "todo" }),
|
||||
], pausedSettings);
|
||||
|
||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("emits no-action audit when a live execution surface still owns the task", async () => {
|
||||
const { store, manager } = setup([
|
||||
makeTask({ id: "FN-ACTIVE", column: "in-review", dependencies: ["FN-D"] }),
|
||||
makeTask({ id: "FN-D", column: "todo" }),
|
||||
]);
|
||||
const isTaskActive = vi.fn((taskId: string) => taskId === "FN-ACTIVE");
|
||||
manager.stop();
|
||||
const guardedManager = new SelfHealingManager(store, { rootDir: "/tmp/test-project", isTaskActive });
|
||||
|
||||
await expect(guardedManager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:reconcile-in-review-unmet-dependencies-no-action",
|
||||
target: "FN-ACTIVE",
|
||||
}));
|
||||
guardedManager.stop();
|
||||
});
|
||||
|
||||
it("emits no-action audit when a task is checked out", async () => {
|
||||
const { store, manager } = setup([
|
||||
makeTask({ id: "FN-CHECKED", column: "in-review", dependencies: ["FN-D"], checkedOutBy: "agent-1" }),
|
||||
makeTask({ id: "FN-D", column: "todo" }),
|
||||
]);
|
||||
|
||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:reconcile-in-review-unmet-dependencies-no-action",
|
||||
target: "FN-CHECKED",
|
||||
}));
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind } from "@fusion/core";
|
||||
import { getUnmetSchedulingDependencies } from "./scheduler.js";
|
||||
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries } from "@fusion/core";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
||||
@@ -6886,6 +6887,50 @@ export class TaskExecutor {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async blockOuterDispatchWhenDependenciesUnmet(task: Task): Promise<boolean> {
|
||||
if (!task.dependencies || task.dependencies.length === 0) return false;
|
||||
|
||||
const settings = await this.store.getSettings();
|
||||
const tasks = await this.store.listTasks({ includeArchived: false, slim: true });
|
||||
const liveTask = tasks.find((candidate) => candidate.id === task.id) ?? task;
|
||||
const markerAcceptedByTaskId = new Map<string, boolean>();
|
||||
if (settings.mergeRequestContractShadowEnabled === true) {
|
||||
for (const depId of liveTask.dependencies) {
|
||||
markerAcceptedByTaskId.set(depId, this.store.getCompletionHandoffAcceptedMarker(depId) !== null);
|
||||
}
|
||||
}
|
||||
const unmetDeps = getUnmetSchedulingDependencies(
|
||||
liveTask,
|
||||
tasks,
|
||||
settings.mergeRequestContractShadowEnabled === true ? { markerAcceptedByTaskId } : undefined,
|
||||
);
|
||||
if (unmetDeps.length === 0) return false;
|
||||
|
||||
/*
|
||||
FNXC:DependencyGating 2026-06-20-07:30:
|
||||
Workflow-graph and workflow-authoritative executor dispatches can be invoked outside the classic scheduler loop, so they must re-apply the shared scheduling dependency gate before graph routing, column-agent seams, or review handoff can run.
|
||||
Requeue with blockedBy instead of executing so missing or soft-deleted dependency residue keeps the scheduler helper's non-blocking semantics while live todo/queued/in-progress/triage dependencies block every dispatch surface.
|
||||
*/
|
||||
if (liveTask.column !== "todo") {
|
||||
await this.store.moveTask(liveTask.id, "todo", {
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
preserveResumeState: true,
|
||||
moveSource: "engine",
|
||||
recoveryRehome: true,
|
||||
});
|
||||
}
|
||||
await this.store.updateTask(liveTask.id, { status: "queued", blockedBy: unmetDeps[0] }, this.getRunContextFor(liveTask.id));
|
||||
await this.store.logEntry(
|
||||
liveTask.id,
|
||||
`queued — unmet dependencies: ${unmetDeps.join(", ")}`,
|
||||
"Executor pre-dispatch dependency gate blocked workflow/authoritative execution.",
|
||||
this.getRunContextFor(liveTask.id),
|
||||
);
|
||||
executorLog.log(`${liveTask.id}: executor dispatch blocked by unmet dependencies: ${unmetDeps.join(", ")}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(task: Task): Promise<void> {
|
||||
this.completionFinalizedTaskIds.delete(task.id);
|
||||
// Workflow graph interpreter routing (cutover M-C): graph-selected tasks
|
||||
@@ -6899,6 +6944,7 @@ export class TaskExecutor {
|
||||
executorLog.log(`execute() called for ${task.id} while graph routing is active — skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
if (await this.blockOuterDispatchWhenDependenciesUnmet(task)) return;
|
||||
const graphOwned = await this.maybeExecuteWorkflowGraph(task);
|
||||
if (graphOwned) return;
|
||||
const authoritativeOwned = await this.options.workflowAuthoritativeDispatch?.(task);
|
||||
|
||||
@@ -1164,6 +1164,7 @@ export class SelfHealingManager {
|
||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
|
||||
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies().then(() => undefined) },
|
||||
{ name: "reconcile-dependency-blocking-leases", fn: () => this.reconcileDependencyBlockingLeases().then(() => undefined) },
|
||||
{ name: "reconcile-in-review-unmet-dependencies", fn: () => this.reconcileInReviewUnmetDependencies().then(() => undefined) },
|
||||
{ name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) },
|
||||
{ name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts().then(() => undefined) },
|
||||
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
|
||||
@@ -2177,6 +2178,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-stale-transition-pending", fn: () => this.runStaleTransitionPendingSweep() },
|
||||
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies() },
|
||||
{ name: "reconcile-dependency-blocking-leases", fn: () => this.reconcileDependencyBlockingLeases() },
|
||||
{ name: "reconcile-in-review-unmet-dependencies", fn: () => this.reconcileInReviewUnmetDependencies() },
|
||||
// FN-6782: reclaim in-memory worktree slots whose holder is no longer
|
||||
// in-progress (defense-in-depth for the pause-abort leak; conservative,
|
||||
// gated by clearPhantomExecutorBinding's live-session refusal).
|
||||
@@ -4981,6 +4983,116 @@ export class SelfHealingManager {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
private evaluateInReviewUnmetDependencyReboundSafety(task: Task, settings: Settings, unmetDeps: string[]): { ok: boolean; stalenessMs: number; reason: string; metadata: Record<string, unknown> } {
|
||||
const livePaths = activeSessionRegistry.pathsForTask(task.id);
|
||||
const hasActiveRegisteredPath = livePaths.some((path) => activeSessionRegistry.isPathActive(path));
|
||||
const sessionDead = !hasActiveRegisteredPath && !executingTaskLock.has(task.id) && this.options.isTaskActive?.(task.id) !== true;
|
||||
const anchorMs = task.columnMovedAt ? Date.parse(task.columnMovedAt) : Date.parse(task.updatedAt ?? "");
|
||||
const stalenessMs = Number.isFinite(anchorMs) ? Math.max(0, Date.now() - anchorMs) : Number.POSITIVE_INFINITY;
|
||||
const ok = sessionDead && !task.checkedOutBy;
|
||||
return {
|
||||
ok,
|
||||
stalenessMs,
|
||||
reason: "in-review-unmet-dependencies",
|
||||
metadata: {
|
||||
taskId: task.id,
|
||||
unmetDeps,
|
||||
blockedBy: unmetDeps[0] ?? null,
|
||||
priorColumn: task.column,
|
||||
priorStatus: task.status ?? null,
|
||||
priorWorktree: task.worktree ?? null,
|
||||
priorBranch: task.branch ?? null,
|
||||
stalenessMs,
|
||||
sessionDead,
|
||||
livePaths,
|
||||
hasActiveRegisteredPath,
|
||||
hasExecutingTaskLock: executingTaskLock.has(task.id),
|
||||
taskActive: this.options.isTaskActive?.(task.id) === true,
|
||||
checkedOutBy: task.checkedOutBy ?? null,
|
||||
autoMerge: settings.autoMerge ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async reconcileInReviewUnmetDependencies(): Promise<number> {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
let tasks: Task[] = [];
|
||||
try {
|
||||
tasks = await this.store.listTasks({ includeArchived: false, slim: true });
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`reconcileInReviewUnmetDependencies: failed to list tasks: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const markerAcceptedByTaskId = new Map<string, boolean>();
|
||||
if (settings.mergeRequestContractShadowEnabled === true) {
|
||||
const dependencyIds = new Set(tasks.flatMap((task) => task.dependencies));
|
||||
for (const depId of dependencyIds) {
|
||||
markerAcceptedByTaskId.set(depId, this.store.getCompletionHandoffAcceptedMarker(depId) !== null);
|
||||
}
|
||||
}
|
||||
const dependencyOptions = settings.mergeRequestContractShadowEnabled === true
|
||||
? { markerAcceptedByTaskId }
|
||||
: undefined;
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of tasks) {
|
||||
if (task.column !== "in-review" || task.deletedAt) continue;
|
||||
if (task.paused === true || task.userPaused === true) continue;
|
||||
if (!allowsAutoMergeProcessing(task, settings)) continue;
|
||||
|
||||
const unmetDeps = getUnmetSchedulingDependencies(task, tasks, dependencyOptions);
|
||||
if (unmetDeps.length === 0) continue;
|
||||
|
||||
const proof = this.evaluateInReviewUnmetDependencyReboundSafety(task, settings, unmetDeps);
|
||||
if (!proof.ok) {
|
||||
await this.emitBackwardMoveNoAction(
|
||||
task,
|
||||
"reconcile-in-review-unmet-dependencies",
|
||||
"task:reconcile-in-review-unmet-dependencies-no-action",
|
||||
proof,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
preserveResumeState: true,
|
||||
moveSource: "engine",
|
||||
recoveryRehome: true,
|
||||
});
|
||||
await this.store.updateTask(task.id, { status: "queued", blockedBy: unmetDeps[0] });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-rebounded (FN-6793): in-review task had unmet dependencies: ${unmetDeps.join(", ")}`,
|
||||
);
|
||||
await createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("fn6793-in-review-unmet-dependencies", task.id),
|
||||
agentId: "self-healing",
|
||||
taskId: task.id,
|
||||
taskLineageId: task.lineageId,
|
||||
phase: "reconcile-in-review-unmet-dependencies",
|
||||
}).database({
|
||||
type: "task:reconcile-in-review-unmet-dependencies" as DatabaseMutationType,
|
||||
target: task.id,
|
||||
metadata: {
|
||||
taskId: task.id,
|
||||
unmetDeps,
|
||||
blockedBy: unmetDeps[0] ?? null,
|
||||
priorColumn: "in-review",
|
||||
priorStatus: task.status ?? null,
|
||||
},
|
||||
});
|
||||
recovered++;
|
||||
}
|
||||
|
||||
return recovered;
|
||||
}
|
||||
|
||||
async reconcileSelfDefeatingDependencies(): Promise<number> {
|
||||
const targetColumns: Array<Task["column"]> = ["triage", "todo"];
|
||||
let recovered = 0;
|
||||
|
||||
Reference in New Issue
Block a user