feat(FN-3539): remove duplicate todos entry and update navigation docs
Merged branch removes the duplicate desktop "Todos" nav entry from the Header, syncs the `allowParallelExecution` runtime toggle into `AgentDetailView`, and updates the corresponding docs (agents.md, settings-reference.md, todo-view.md, dashboard-guide.md) to reflect the navigation change. Tests wer Fusion-Task-Id: FN-3539
This commit is contained in:
@@ -269,7 +269,7 @@ function createMockStore() {
|
||||
setPluginWorkflowStepTemplates: vi.fn(),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
||||
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
return store as any;
|
||||
}
|
||||
@@ -1057,7 +1057,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default base and clears task.baseBranch when the configured base ref is missing (FN-2165)", async () => {
|
||||
it("falls back to default base and clears task.executionStartBranch when the configured base ref is missing (FN-2165)", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
@@ -1074,7 +1074,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
|
||||
const onError = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||
await executor.execute({ ...makeTask(), baseBranch: "fusion/missing-base" });
|
||||
await executor.execute({ ...makeTask(), executionStartBranch: "fusion/missing-base" });
|
||||
|
||||
// Should log the soft fallback, not a terminal failure
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
@@ -1085,7 +1085,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
// Should clear baseBranch on the task so retries use the default
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.objectContaining({ baseBranch: null }),
|
||||
expect.objectContaining({ executionStartBranch: null }),
|
||||
);
|
||||
// Should proceed to create a worktree from HEAD (no startPoint)
|
||||
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
||||
@@ -1342,7 +1342,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
mockedGenerateWorktreeName.mockReturnValueOnce("jade-finch");
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({ ...makeTask(), baseBranch: "fusion/fn-049" });
|
||||
await executor.execute({ ...makeTask(), executionStartBranch: "fusion/fn-049" });
|
||||
|
||||
// Should log that we're trying a new path
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
@@ -1805,7 +1805,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-060",
|
||||
baseBranch: "fusion/fn-059",
|
||||
executionStartBranch: "fusion/fn-059",
|
||||
}));
|
||||
|
||||
// The git worktree add command should include the startPoint
|
||||
@@ -1843,7 +1843,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-062",
|
||||
baseBranch: "fusion/fn-061",
|
||||
executionStartBranch: "fusion/fn-061",
|
||||
}));
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
@@ -1969,7 +1969,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-064",
|
||||
baseBranch: "fusion/fn-063",
|
||||
executionStartBranch: "fusion/fn-063",
|
||||
}));
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -187,7 +187,7 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
@@ -834,7 +834,7 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Layer 1 recovery: surgically drops dep commits via rebase --onto when baseBranch is set and primary rebase aborted", async () => {
|
||||
it("Layer 1 recovery: surgically drops dep commits via rebase --onto when executionStartBranch is set and primary rebase aborted", async () => {
|
||||
// Scenario: FN-2849 declared baseBranch=fusion/fn-2729 (a dep). The
|
||||
// worktree was forked off FN-2729's tip and inherited its raw commits.
|
||||
// FN-2729 was later squash-merged to main. Now the primary rebase onto
|
||||
@@ -845,7 +845,7 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
|
||||
const store = createMockStore(
|
||||
{
|
||||
id: "FN-2849",
|
||||
baseBranch: "fusion/fn-2729",
|
||||
executionStartBranch: "fusion/fn-2729",
|
||||
branch: "fusion/fn-2849",
|
||||
worktree: "/tmp/root/.worktrees/coral-stone",
|
||||
},
|
||||
|
||||
@@ -854,7 +854,7 @@ describe("Scheduler", () => {
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-010", {
|
||||
status: null,
|
||||
blockedBy: null,
|
||||
baseBranch: undefined,
|
||||
executionStartBranch: undefined,
|
||||
worktree: "/test/project/.worktrees/fn-010",
|
||||
effectiveNodeId: null,
|
||||
effectiveNodeSource: "local",
|
||||
@@ -893,7 +893,7 @@ describe("Scheduler", () => {
|
||||
expect(updateTask).toHaveBeenNthCalledWith(1, "FN-011", {
|
||||
status: null,
|
||||
blockedBy: null,
|
||||
baseBranch: undefined,
|
||||
executionStartBranch: undefined,
|
||||
worktree: "/test/project/.worktrees/amber-aspen",
|
||||
effectiveNodeId: null,
|
||||
effectiveNodeSource: "local",
|
||||
@@ -902,7 +902,7 @@ describe("Scheduler", () => {
|
||||
expect(updateTask).toHaveBeenNthCalledWith(2, "FN-012", {
|
||||
status: null,
|
||||
blockedBy: null,
|
||||
baseBranch: undefined,
|
||||
executionStartBranch: undefined,
|
||||
worktree: "/test/project/.worktrees/amber-aspen-2",
|
||||
effectiveNodeId: null,
|
||||
effectiveNodeSource: "local",
|
||||
|
||||
@@ -119,7 +119,7 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
|
||||
walCheckpoint: vi.fn().mockReturnValue({ busy: 0, log: 5, checkpointed: 5 }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/test-project"),
|
||||
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
...overrides,
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
return store;
|
||||
|
||||
@@ -2165,7 +2165,7 @@ export class TaskExecutor {
|
||||
let acquiredFromPool = false;
|
||||
|
||||
// Resolve the base branch — set by the scheduler when a dep is in-review
|
||||
const baseBranch = task.baseBranch || null;
|
||||
const baseBranch = task.executionStartBranch || null;
|
||||
|
||||
if (task.worktree && isResume && !await isUsableTaskWorktree(this.rootDir, worktreePath)) {
|
||||
const invalidWorktreePath = worktreePath;
|
||||
@@ -4227,7 +4227,7 @@ export class TaskExecutor {
|
||||
}
|
||||
if (branchDeleted) {
|
||||
// FN-2165 regression guard: null baseBranch on any task that stored this branch
|
||||
try { this.store.clearStaleBaseBranchReferences([branch], taskId); } catch { /* best-effort */ }
|
||||
try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Clear worktree tracking
|
||||
@@ -5438,7 +5438,7 @@ and show an appropriate message to the user.\`
|
||||
// Stored baseBranch no longer exists (e.g., upstream dep merged and branch
|
||||
// deleted while this task sat queued/stuck). Clear it on the task so any
|
||||
// subsequent retry branches from the default base, and proceed from HEAD.
|
||||
await this.store.updateTask(taskId, { baseBranch: null });
|
||||
await this.store.updateTask(taskId, { executionStartBranch: null });
|
||||
} else {
|
||||
resolvedStartPoint = resolved;
|
||||
}
|
||||
@@ -6163,7 +6163,7 @@ and show an appropriate message to the user.\`
|
||||
});
|
||||
await this.store.logEntry(taskId, `Deleted branch`, branch);
|
||||
// FN-2165 regression guard: null baseBranch on any task that stored this branch
|
||||
this.store.clearStaleBaseBranchReferences([branch], taskId);
|
||||
this.store.clearStaleExecutionStartBranchReferences([branch], taskId);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
executorLog.warn(`${taskId}: failed to delete conflicting branch ${branch}: ${msg}`);
|
||||
@@ -6210,7 +6210,7 @@ and show an appropriate message to the user.\`
|
||||
});
|
||||
await this.store.logEntry(taskId, `Removed stale branch`, branch);
|
||||
// FN-2165 regression guard: null baseBranch on any task that stored this branch
|
||||
try { this.store.clearStaleBaseBranchReferences([branch], taskId); } catch { /* best-effort */ }
|
||||
try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
|
||||
return true;
|
||||
} catch (branchDeleteError: unknown) {
|
||||
const branchDeleteErrorMessage = branchDeleteError instanceof Error ? branchDeleteError.message : String(branchDeleteError);
|
||||
@@ -6229,7 +6229,7 @@ and show an appropriate message to the user.\`
|
||||
});
|
||||
await this.store.logEntry(taskId, `Force-removed stale branch reference via update-ref`, refPath);
|
||||
// FN-2165 regression guard: null baseBranch on any task that stored this branch
|
||||
try { this.store.clearStaleBaseBranchReferences([branch], taskId); } catch { /* best-effort */ }
|
||||
try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
|
||||
return true;
|
||||
} catch (updateRefError: unknown) {
|
||||
const updateRefErrorMessage = updateRefError instanceof Error ? updateRefError.message : String(updateRefError);
|
||||
|
||||
@@ -3482,19 +3482,19 @@ export async function aiMergeTask(
|
||||
}
|
||||
|
||||
// Layer 1: surgical drop of declared-dependency commits.
|
||||
// When `task.baseBranch` is a non-main branch (a sibling task's branch),
|
||||
// When `task.executionStartBranch` is a non-main branch (a sibling task's branch),
|
||||
// the dependent worktree was forked off it and inherited its commits.
|
||||
// If the dep was later squash-merged to main, those raw commits are now
|
||||
// orphans whose content already exists in main. Re-rebase the task
|
||||
// branch onto main using `git rebase --onto <target> <dep-tip> <branch>`,
|
||||
// which peels off the dep's commits cleanly.
|
||||
if (rebaseTarget && task.baseBranch && task.baseBranch !== "main") {
|
||||
if (rebaseTarget && task.executionStartBranch && task.executionStartBranch !== "main") {
|
||||
// Resolve the dep's tip — prefer the live branch ref, fall back to
|
||||
// the recorded baseCommitSha if the branch was already deleted.
|
||||
let depTip: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-parse --verify "${task.baseBranch}^{commit}"`,
|
||||
`git rev-parse --verify "${task.executionStartBranch}^{commit}"`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
depTip = stdout.trim() || undefined;
|
||||
@@ -3528,11 +3528,11 @@ export async function aiMergeTask(
|
||||
preferMainRebaseFailureMessage = undefined;
|
||||
rebaseHappened = true;
|
||||
mergerLog.log(
|
||||
`${taskId}: Layer 1 recovery — rebased ${branch} --onto ${rebaseTarget.slice(0, 8)} dropping commits up to dep tip ${depTip.slice(0, 8)} (baseBranch=${task.baseBranch})`,
|
||||
`${taskId}: Layer 1 recovery — rebased ${branch} --onto ${rebaseTarget.slice(0, 8)} dropping commits up to dep tip ${depTip.slice(0, 8)} (executionStartBranch=${task.executionStartBranch})`,
|
||||
);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Pre-merge recovery (Layer 1): dropped dependency commits from ${task.baseBranch} via rebase --onto ${rebaseTarget.slice(0, 8)} ${depTip.slice(0, 8)} ${branch}; the merge will proceed against the cleaned branch`,
|
||||
`Pre-merge recovery (Layer 1): dropped dependency commits from ${task.executionStartBranch} via rebase --onto ${rebaseTarget.slice(0, 8)} ${depTip.slice(0, 8)} ${branch}; the merge will proceed against the cleaned branch`,
|
||||
);
|
||||
} catch (layer1Err) {
|
||||
rethrowIfMergeAborted(layer1Err);
|
||||
@@ -4348,7 +4348,7 @@ export async function aiMergeTask(
|
||||
// conflict-suffixed branch), null it so the dependent task doesn't
|
||||
// hard-fail at worktree creation once this branch is gone.
|
||||
try {
|
||||
const cleared = store.clearStaleBaseBranchReferences([branch], taskId);
|
||||
const cleared = store.clearStaleExecutionStartBranchReferences([branch], taskId);
|
||||
if (cleared.length > 0) {
|
||||
mergerLog.log(`${taskId}: cleared stale baseBranch on ${cleared.length} dependent task(s): ${cleared.join(", ")}`);
|
||||
}
|
||||
|
||||
@@ -835,7 +835,7 @@ export class Scheduler {
|
||||
await this.store.updateTask(task.id, {
|
||||
status: null,
|
||||
blockedBy: null,
|
||||
baseBranch: baseBranch ?? undefined,
|
||||
executionStartBranch: baseBranch ?? undefined,
|
||||
worktree: plannedWorktree,
|
||||
effectiveNodeId: effectiveNode.nodeId ?? null,
|
||||
effectiveNodeSource: effectiveNode.source,
|
||||
|
||||
@@ -2079,7 +2079,7 @@ export class SelfHealingManager {
|
||||
// FN-2165 regression guard: if any dependent task stored one of these
|
||||
// now-gone branches as its baseBranch, null it so the task doesn't
|
||||
// hard-fail at worktree creation time.
|
||||
const cleared = this.store.clearStaleBaseBranchReferences(deletedBranches);
|
||||
const cleared = this.store.clearStaleExecutionStartBranchReferences(deletedBranches);
|
||||
if (cleared.length > 0) {
|
||||
log.log(`Cleared stale baseBranch on ${cleared.length} task(s): ${cleared.join(", ")}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user