FN-6797: harden in-review dependency rebound audits
Harden in-review dependency drift reconciliation with explicit guarded no-action audit evidence. - Emit no-action run-audit events when pause, auto-merge, live execution, checkout, or rebound mutation guards prevent an in-review dependency rebound. - Preserve scheduler dependency satisfaction semantics so in-review dependencies remain non-blocking under merge-request shadow mode without accepted markers. - Extend regression coverage and run-audit documentation for guarded in-review rebounds. Files changed: .changeset/fn-6797-in-review-dependency-drift.md | 5 + AGENTS.md | 2 +- docs/architecture.md | 4 +- .../in-review-unmet-dependency-reconcile.test.ts | 18 ++- packages/engine/src/__tests__/self-healing.test.ts | 79 +++++++++++-- packages/engine/src/self-healing.ts | 129 ++++++++++++++++----- 6 files changed, 190 insertions(+), 47 deletions(-) Fusion-Task-Id: FN-6797 Fusion-Task-Lineage: f94c7875-619a-47be-88f5-320a4dda3b34
This commit is contained in:
5
.changeset/fn-6797-in-review-dependency-drift.md
Normal file
5
.changeset/fn-6797-in-review-dependency-drift.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Harden in-review dependency drift reconciliation so guard-held or failed rebounds emit no-action audit evidence instead of silently wedging dependent tasks.
|
||||||
@@ -193,7 +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-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-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-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.
|
- FN-6793/FN-6797: 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 pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move.
|
||||||
|
|
||||||
|
|
||||||
## Reference docs (deeper detail)
|
## Reference docs (deeper detail)
|
||||||
|
|||||||
@@ -628,7 +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.
|
- 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.
|
- 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`.
|
- `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`.
|
- `reconcileInReviewUnmetDependencies()` (FN-6793/FN-6797) 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 short-circuits the sweep; task pause/user-pause, `autoMerge:false`, live execution, checkout guards, and failed rebound mutations 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
|
- `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.
|
- `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
|
- `TaskCompletion` (`task-completion.ts`) — completion gate helpers
|
||||||
@@ -1088,7 +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 / `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 / `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-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-in-review-unmet-dependencies`** — emitted by `reconcileInReviewUnmetDependencies()` (FN-6793/FN-6797) 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 task pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation 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: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.
|
- **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.
|
- **Filesystem** — file:write, prompt:write, attachment:create, etc.
|
||||||
|
|||||||
@@ -90,25 +90,31 @@ describe("in-review unmet dependency reconciliation", () => {
|
|||||||
executingTaskLock._clearForTest();
|
executingTaskLock._clearForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reproduces FN-6778/FN-6791-class review advancement and rebounds to queued todo", async () => {
|
it("reproduces FN-6778/FN-6779 review advancement and rebounds to queued todo", async () => {
|
||||||
const { store, tasks } = createStore([
|
const { store, tasks } = createStore([
|
||||||
task({ id: "FN-6778", column: "in-review", dependencies: ["FN-6777"] }),
|
task({ id: "FN-6778", column: "in-review", dependencies: ["FN-6777"] }),
|
||||||
task({ id: "FN-6777", column: "todo" }),
|
task({ id: "FN-6777", column: "in-progress" }),
|
||||||
task({ id: "FN-6791", column: "in-review", dependencies: ["FN-6770", "FN-6771", "FN-6780"] }),
|
task({ id: "FN-6779", column: "in-review", dependencies: ["FN-6770", "FN-6771", "FN-6780", "FN-TRIAGE"] }),
|
||||||
task({ id: "FN-6770", column: "in-progress" }),
|
task({ id: "FN-6770", column: "in-progress" }),
|
||||||
task({ id: "FN-6771", column: "todo" }),
|
task({ id: "FN-6771", column: "todo" }),
|
||||||
task({ id: "FN-6780", column: "todo", status: "queued" }),
|
task({ id: "FN-6780", column: "todo", status: "queued" }),
|
||||||
|
task({ id: "FN-TRIAGE", column: "triage" }),
|
||||||
]);
|
]);
|
||||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
|
|
||||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(2);
|
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(2);
|
||||||
|
|
||||||
expect(tasks.get("FN-6778")).toMatchObject({ column: "todo", status: "queued", blockedBy: "FN-6777" });
|
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(tasks.get("FN-6779")).toMatchObject({ column: "todo", status: "queued", blockedBy: "FN-6770" });
|
||||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||||
target: "FN-6791",
|
target: "FN-6778",
|
||||||
metadata: expect.objectContaining({ unmetDeps: ["FN-6770", "FN-6771", "FN-6780"] }),
|
metadata: expect.objectContaining({ unmetDeps: ["FN-6777"] }),
|
||||||
|
}));
|
||||||
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||||
|
target: "FN-6779",
|
||||||
|
metadata: expect.objectContaining({ unmetDeps: ["FN-6770", "FN-6771", "FN-6780", "FN-TRIAGE"] }),
|
||||||
}));
|
}));
|
||||||
manager.stop();
|
manager.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9457,10 +9457,11 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
|||||||
const { store, manager, tasks } = setup([
|
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-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-6777", column: "todo", status: "queued" }),
|
||||||
makeTask({ id: "FN-6791", column: "in-review", dependencies: ["FN-6770", "FN-6771", "FN-6780", "FN-DONE"] }),
|
makeTask({ id: "FN-6779", column: "in-review", dependencies: ["FN-6770", "FN-6771", "FN-6780", "FN-TRIAGE", "FN-DONE"] }),
|
||||||
makeTask({ id: "FN-6770", column: "in-progress" }),
|
makeTask({ id: "FN-6770", column: "in-progress" }),
|
||||||
makeTask({ id: "FN-6771", column: "todo" }),
|
makeTask({ id: "FN-6771", column: "todo" }),
|
||||||
makeTask({ id: "FN-6780", column: "todo", status: "queued" }),
|
makeTask({ id: "FN-6780", column: "todo", status: "queued" }),
|
||||||
|
makeTask({ id: "FN-TRIAGE", column: "triage" }),
|
||||||
makeTask({ id: "FN-DONE", column: "done" }),
|
makeTask({ id: "FN-DONE", column: "done" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -9472,12 +9473,13 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
|||||||
preserveResumeState: true,
|
preserveResumeState: true,
|
||||||
moveSource: "engine",
|
moveSource: "engine",
|
||||||
recoveryRehome: true,
|
recoveryRehome: true,
|
||||||
|
bypassGuards: true,
|
||||||
}));
|
}));
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("FN-6778", { status: "queued", blockedBy: "FN-6777" });
|
expect(store.updateTask).toHaveBeenCalledWith("FN-6778", { status: "queued", blockedBy: "FN-6777" });
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("FN-6791", { status: "queued", blockedBy: "FN-6770" });
|
expect(store.updateTask).toHaveBeenCalledWith("FN-6779", { status: "queued", blockedBy: "FN-6770" });
|
||||||
expect(tasks.get("FN-6778")?.column).toBe("todo");
|
expect(tasks.get("FN-6778")?.column).toBe("todo");
|
||||||
expect(tasks.get("FN-6778")?.blockedBy).toBe("FN-6777");
|
expect(tasks.get("FN-6778")?.blockedBy).toBe("FN-6777");
|
||||||
expect(tasks.get("FN-6791")?.blockedBy).toBe("FN-6770");
|
expect(tasks.get("FN-6779")?.blockedBy).toBe("FN-6770");
|
||||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||||
target: "FN-6778",
|
target: "FN-6778",
|
||||||
@@ -9485,8 +9487,8 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
|||||||
}));
|
}));
|
||||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||||
target: "FN-6791",
|
target: "FN-6779",
|
||||||
metadata: expect.objectContaining({ unmetDeps: ["FN-6770", "FN-6771", "FN-6780"], blockedBy: "FN-6770" }),
|
metadata: expect.objectContaining({ unmetDeps: ["FN-6770", "FN-6771", "FN-6780", "FN-TRIAGE"], blockedBy: "FN-6770" }),
|
||||||
}));
|
}));
|
||||||
manager.stop();
|
manager.stop();
|
||||||
});
|
});
|
||||||
@@ -9505,7 +9507,24 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
|||||||
manager.stop();
|
manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([{ userPaused: true }, { paused: true }])("does not move paused in-review tasks: %o", async (pauseState) => {
|
it("keeps in-review dependencies non-blocking when shadow contract is enabled without an accepted marker", async () => {
|
||||||
|
const { store, manager } = setup([
|
||||||
|
makeTask({ id: "FN-MARKER", column: "in-review", dependencies: ["FN-D"] }),
|
||||||
|
makeTask({ id: "FN-D", column: "in-review" }),
|
||||||
|
], { mergeRequestContractShadowEnabled: true });
|
||||||
|
vi.mocked(store.getCompletionHandoffAcceptedMarker).mockReturnValue(null);
|
||||||
|
|
||||||
|
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||||
|
expect(store.getCompletionHandoffAcceptedMarker).toHaveBeenCalledWith("FN-D");
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
expect(store.recordRunAuditEvent).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||||
|
target: "FN-MARKER",
|
||||||
|
}));
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([{ userPaused: true }, { paused: true }])("does not move paused in-review tasks and emits no-action audit: %o", async (pauseState) => {
|
||||||
const { store, manager } = setup([
|
const { store, manager } = setup([
|
||||||
makeTask({ id: "FN-P", column: "in-review", dependencies: ["FN-D"], ...pauseState }),
|
makeTask({ id: "FN-P", column: "in-review", dependencies: ["FN-D"], ...pauseState }),
|
||||||
makeTask({ id: "FN-D", column: "todo" }),
|
makeTask({ id: "FN-D", column: "todo" }),
|
||||||
@@ -9513,10 +9532,15 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
|||||||
|
|
||||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
mutationType: "task:reconcile-in-review-unmet-dependencies-no-action",
|
||||||
|
target: "FN-P",
|
||||||
|
metadata: expect.objectContaining({ reason: "paused-guard" }),
|
||||||
|
}));
|
||||||
manager.stop();
|
manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honors autoMerge false as terminal-until-merged", async () => {
|
it("honors autoMerge false as terminal-until-merged and emits no-action audit", async () => {
|
||||||
const { store, manager } = setup([
|
const { store, manager } = setup([
|
||||||
makeTask({ id: "FN-AUTO", column: "in-review", dependencies: ["FN-D"] }),
|
makeTask({ id: "FN-AUTO", column: "in-review", dependencies: ["FN-D"] }),
|
||||||
makeTask({ id: "FN-D", column: "todo" }),
|
makeTask({ id: "FN-D", column: "todo" }),
|
||||||
@@ -9524,6 +9548,47 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
|||||||
|
|
||||||
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(0);
|
||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
mutationType: "task:reconcile-in-review-unmet-dependencies-no-action",
|
||||||
|
target: "FN-AUTO",
|
||||||
|
metadata: expect.objectContaining({ reason: "auto-merge-processing-disabled", autoMerge: false }),
|
||||||
|
}));
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets explicit autoMerge true rebound when global autoMerge is false", async () => {
|
||||||
|
const { store, manager, tasks } = setup([
|
||||||
|
makeTask({ id: "FN-OVERRIDE", column: "in-review", dependencies: ["FN-D"], autoMerge: true }),
|
||||||
|
makeTask({ id: "FN-D", column: "todo" }),
|
||||||
|
], { autoMerge: false });
|
||||||
|
|
||||||
|
await expect(manager.reconcileInReviewUnmetDependencies()).resolves.toBe(1);
|
||||||
|
expect(tasks.get("FN-OVERRIDE")).toMatchObject({ column: "todo", status: "queued", blockedBy: "FN-D" });
|
||||||
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
mutationType: "task:reconcile-in-review-unmet-dependencies",
|
||||||
|
target: "FN-OVERRIDE",
|
||||||
|
}));
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps shared-branch-group members held under autoMerge false with no-action audit", async () => {
|
||||||
|
const { store, manager } = setup([
|
||||||
|
makeTask({
|
||||||
|
id: "FN-SHARED",
|
||||||
|
column: "in-review",
|
||||||
|
dependencies: ["FN-D"],
|
||||||
|
branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"],
|
||||||
|
}),
|
||||||
|
makeTask({ id: "FN-D", column: "todo" }),
|
||||||
|
], { autoMerge: false });
|
||||||
|
|
||||||
|
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-SHARED",
|
||||||
|
metadata: expect.objectContaining({ reason: "auto-merge-processing-disabled" }),
|
||||||
|
}));
|
||||||
manager.stop();
|
manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5041,12 +5041,60 @@ export class SelfHealingManager {
|
|||||||
let recovered = 0;
|
let recovered = 0;
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.column !== "in-review" || task.deletedAt) continue;
|
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);
|
const unmetDeps = getUnmetSchedulingDependencies(task, tasks, dependencyOptions);
|
||||||
if (unmetDeps.length === 0) continue;
|
if (unmetDeps.length === 0) continue;
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DependencyGating 2026-06-20-09:22:
|
||||||
|
In-review tasks with unmet dependencies must never be silently wedged. If a guard or store mutation prevents the rebound, emit the FN-6793 no-action audit event with the blocking reason so operators can distinguish allowed terminal holds from lifecycle bugs.
|
||||||
|
*/
|
||||||
|
if (task.paused === true || task.userPaused === true) {
|
||||||
|
await this.emitBackwardMoveNoAction(
|
||||||
|
task,
|
||||||
|
"reconcile-in-review-unmet-dependencies",
|
||||||
|
"task:reconcile-in-review-unmet-dependencies-no-action",
|
||||||
|
{
|
||||||
|
stalenessMs: 0,
|
||||||
|
reason: "in-review-unmet-dependencies-paused",
|
||||||
|
metadata: {
|
||||||
|
taskId: task.id,
|
||||||
|
unmetDeps,
|
||||||
|
blockedBy: unmetDeps[0] ?? null,
|
||||||
|
priorColumn: task.column,
|
||||||
|
priorStatus: task.status ?? null,
|
||||||
|
paused: task.paused === true,
|
||||||
|
userPaused: task.userPaused === true,
|
||||||
|
reason: "paused-guard",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowsAutoMergeProcessing(task, settings)) {
|
||||||
|
await this.emitBackwardMoveNoAction(
|
||||||
|
task,
|
||||||
|
"reconcile-in-review-unmet-dependencies",
|
||||||
|
"task:reconcile-in-review-unmet-dependencies-no-action",
|
||||||
|
{
|
||||||
|
stalenessMs: 0,
|
||||||
|
reason: "in-review-unmet-dependencies-auto-merge-disabled",
|
||||||
|
metadata: {
|
||||||
|
taskId: task.id,
|
||||||
|
unmetDeps,
|
||||||
|
blockedBy: unmetDeps[0] ?? null,
|
||||||
|
priorColumn: task.column,
|
||||||
|
priorStatus: task.status ?? null,
|
||||||
|
autoMerge: settings.autoMerge ?? null,
|
||||||
|
taskAutoMerge: task.autoMerge ?? null,
|
||||||
|
reason: "auto-merge-processing-disabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const proof = this.evaluateInReviewUnmetDependencyReboundSafety(task, settings, unmetDeps);
|
const proof = this.evaluateInReviewUnmetDependencyReboundSafety(task, settings, unmetDeps);
|
||||||
if (!proof.ok) {
|
if (!proof.ok) {
|
||||||
await this.emitBackwardMoveNoAction(
|
await this.emitBackwardMoveNoAction(
|
||||||
@@ -5058,36 +5106,55 @@ export class SelfHealingManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.store.moveTask(task.id, "todo", {
|
try {
|
||||||
preserveProgress: true,
|
await this.store.moveTask(task.id, "todo", {
|
||||||
preserveWorktree: true,
|
preserveProgress: true,
|
||||||
preserveResumeState: true,
|
preserveWorktree: true,
|
||||||
moveSource: "engine",
|
preserveResumeState: true,
|
||||||
recoveryRehome: true,
|
moveSource: "engine",
|
||||||
});
|
recoveryRehome: true,
|
||||||
await this.store.updateTask(task.id, { status: "queued", blockedBy: unmetDeps[0] });
|
bypassGuards: true,
|
||||||
await this.store.logEntry(
|
});
|
||||||
task.id,
|
await this.store.updateTask(task.id, { status: "queued", blockedBy: unmetDeps[0] });
|
||||||
`Auto-rebounded (FN-6793): in-review task had unmet dependencies: ${unmetDeps.join(", ")}`,
|
await this.store.logEntry(
|
||||||
);
|
task.id,
|
||||||
await createRunAuditor(this.store, {
|
`Auto-rebounded (FN-6793): in-review task had unmet dependencies: ${unmetDeps.join(", ")}`,
|
||||||
runId: generateSyntheticRunId("fn6793-in-review-unmet-dependencies", task.id),
|
);
|
||||||
agentId: "self-healing",
|
await createRunAuditor(this.store, {
|
||||||
taskId: task.id,
|
runId: generateSyntheticRunId("fn6793-in-review-unmet-dependencies", task.id),
|
||||||
taskLineageId: task.lineageId,
|
agentId: "self-healing",
|
||||||
phase: "reconcile-in-review-unmet-dependencies",
|
|
||||||
}).database({
|
|
||||||
type: "task:reconcile-in-review-unmet-dependencies" as DatabaseMutationType,
|
|
||||||
target: task.id,
|
|
||||||
metadata: {
|
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
unmetDeps,
|
taskLineageId: task.lineageId,
|
||||||
blockedBy: unmetDeps[0] ?? null,
|
phase: "reconcile-in-review-unmet-dependencies",
|
||||||
priorColumn: "in-review",
|
}).database({
|
||||||
priorStatus: task.status ?? null,
|
type: "task:reconcile-in-review-unmet-dependencies" as DatabaseMutationType,
|
||||||
},
|
target: task.id,
|
||||||
});
|
metadata: {
|
||||||
recovered++;
|
taskId: task.id,
|
||||||
|
unmetDeps,
|
||||||
|
blockedBy: unmetDeps[0] ?? null,
|
||||||
|
priorColumn: "in-review",
|
||||||
|
priorStatus: task.status ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
recovered++;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.emitBackwardMoveNoAction(
|
||||||
|
task,
|
||||||
|
"reconcile-in-review-unmet-dependencies",
|
||||||
|
"task:reconcile-in-review-unmet-dependencies-no-action",
|
||||||
|
{
|
||||||
|
stalenessMs: proof.stalenessMs,
|
||||||
|
reason: "in-review-unmet-dependencies-rebound-failed",
|
||||||
|
metadata: {
|
||||||
|
...proof.metadata,
|
||||||
|
reason: "rebound-mutation-failed",
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return recovered;
|
return recovered;
|
||||||
|
|||||||
Reference in New Issue
Block a user