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:
Fusion
2026-05-06 03:39:37 -07:00
committed by gsxdsm
parent e518ce0b6c
commit 79a6b2c840
14 changed files with 101 additions and 72 deletions

View File

@@ -6722,7 +6722,8 @@ Task with acceptance criteria
// Full reset: prior branch/summary/recovery state discarded so the next
// run starts from scratch.
expect(retried.branch).toBeUndefined();
expect(retried.baseBranch).toBeUndefined();
expect(retried.baseBranch).toBe("main");
expect(retried.executionStartBranch).toBeUndefined();
expect(retried.baseCommitSha).toBeUndefined();
expect(retried.summary).toBeUndefined();
expect(retried.recoveryRetryCount).toBeUndefined();
@@ -6761,7 +6762,8 @@ Task with acceptance criteria
expect(respec.blockedBy).toBeUndefined();
expect(respec.workflowStepResults).toBeUndefined();
expect(respec.branch).toBeUndefined();
expect(respec.baseBranch).toBeUndefined();
expect(respec.baseBranch).toBe("main");
expect(respec.executionStartBranch).toBeUndefined();
expect(respec.baseCommitSha).toBeUndefined();
expect(respec.summary).toBeUndefined();
expect(respec.recoveryRetryCount).toBeUndefined();
@@ -7093,13 +7095,13 @@ Task with acceptance criteria
expect(duplicated.blockedBy).toBeUndefined();
});
it("does NOT copy baseBranch", async () => {
it("copies baseBranch", async () => {
const task = await store.createTask({ description: "Test task" });
await store.updateTask(task.id, { baseBranch: "some-branch" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.baseBranch).toBeUndefined();
expect(duplicated.baseBranch).toBe("some-branch");
});
});
@@ -11238,39 +11240,39 @@ describe("RunMutationContext", () => {
});
});
describe("clearStaleBaseBranchReferences (FN-2165)", () => {
describe("clearStaleExecutionStartBranchReferences (FN-2165)", () => {
it("nulls baseBranch on live tasks that reference a deleted branch", async () => {
const upstream = await store.createTask({ description: "Upstream" });
const dependent = await store.createTask({ description: "Dependent" });
await store.updateTask(dependent.id, {
baseBranch: `fusion/${upstream.id.toLowerCase()}-2`,
executionStartBranch: `fusion/${upstream.id.toLowerCase()}-2`,
});
const cleared = store.clearStaleBaseBranchReferences([
const cleared = store.clearStaleExecutionStartBranchReferences([
`fusion/${upstream.id.toLowerCase()}-2`,
]);
expect(cleared).toEqual([dependent.id]);
const reloaded = await store.getTask(dependent.id);
expect(reloaded.baseBranch).toBeUndefined();
expect(reloaded.executionStartBranch).toBeUndefined();
});
it("excludes the owner task so archival doesn't null its own baseBranch", async () => {
const upstream = await store.createTask({ description: "Upstream" });
await store.updateTask(upstream.id, { baseBranch: "fusion/some-base" });
await store.updateTask(upstream.id, { executionStartBranch: "fusion/some-base" });
const cleared = store.clearStaleBaseBranchReferences(
const cleared = store.clearStaleExecutionStartBranchReferences(
["fusion/some-base"],
upstream.id,
);
expect(cleared).toEqual([]);
const reloaded = await store.getTask(upstream.id);
expect(reloaded.baseBranch).toBe("fusion/some-base");
expect(reloaded.executionStartBranch).toBe("fusion/some-base");
});
it("returns [] and is a no-op when no branches given", () => {
expect(store.clearStaleBaseBranchReferences([])).toEqual([]);
expect(store.clearStaleExecutionStartBranchReferences([])).toEqual([]);
});
it("clears baseBranch on multiple dependents in one call", async () => {
@@ -11279,18 +11281,18 @@ describe("RunMutationContext", () => {
store.createTask({ description: "B" }),
store.createTask({ description: "C" }),
]);
await store.updateTask(a.id, { baseBranch: "fusion/gone-a" });
await store.updateTask(b.id, { baseBranch: "fusion/gone-b" });
await store.updateTask(c.id, { baseBranch: "fusion/still-alive" });
await store.updateTask(a.id, { executionStartBranch: "fusion/gone-a" });
await store.updateTask(b.id, { executionStartBranch: "fusion/gone-b" });
await store.updateTask(c.id, { executionStartBranch: "fusion/still-alive" });
const cleared = store.clearStaleBaseBranchReferences([
const cleared = store.clearStaleExecutionStartBranchReferences([
"fusion/gone-a",
"fusion/gone-b",
]);
expect(cleared.sort()).toEqual([a.id, b.id].sort());
const cReloaded = await store.getTask(c.id);
expect(cReloaded.baseBranch).toBe("fusion/still-alive");
expect(cReloaded.executionStartBranch).toBe("fusion/still-alive");
});
});
});

View File

@@ -217,7 +217,7 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
const insertStmt = db.prepare(`
INSERT OR REPLACE INTO tasks (
id, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, baseCommitSha, modelPresetId,
worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId,
modelProvider, modelId, validatorModelProvider, validatorModelId,
mergeRetries, recoveryRetryCount, nextRecoveryAt,
error, summary, thinkingLevel, createdAt, updatedAt,
@@ -226,8 +226,8 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, sliceId
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
@@ -260,6 +260,8 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
task.blockedBy ?? null,
task.paused ? 1 : 0,
task.baseBranch ?? null,
task.branch ?? null,
task.executionStartBranch ?? null,
task.baseCommitSha ?? null,
task.modelPresetId ?? null,
task.modelProvider ?? null,

View File

@@ -165,6 +165,7 @@ CREATE TABLE IF NOT EXISTS tasks (
paused INTEGER DEFAULT 0,
baseBranch TEXT,
branch TEXT,
executionStartBranch TEXT,
baseCommitSha TEXT,
modelPresetId TEXT,
modelProvider TEXT,
@@ -1145,6 +1146,10 @@ export class Database {
private migrate(): void {
const version = this.getSchemaVersion() || 1;
if (this.hasTable("tasks")) {
this.addColumnIfMissing("tasks", "executionStartBranch", "TEXT");
}
if (version >= SCHEMA_VERSION) return;
if (version < 2) {

View File

@@ -42,6 +42,7 @@ interface TaskRow {
blockedBy: string | null;
paused: number | null;
baseBranch: string | null;
executionStartBranch: string | null;
branch: string | null;
baseCommitSha: string | null;
modelPresetId: string | null;
@@ -673,6 +674,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
blockedBy: row.blockedBy || undefined,
paused: row.paused ? true : undefined,
baseBranch: row.baseBranch || undefined,
executionStartBranch: row.executionStartBranch || undefined,
branch: row.branch || undefined,
baseCommitSha: row.baseCommitSha || undefined,
modelPresetId: row.modelPresetId || undefined,
@@ -997,7 +999,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const prefix = tableAlias ? `${tableAlias}.` : "";
return [
"id", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "paused", "baseBranch", "branch", "baseCommitSha",
"worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
@@ -1046,7 +1048,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private getTaskSelectClauseWithActivityLogLimit(limit: number): string {
const columns = [
"id", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "paused", "baseBranch", "branch", "baseCommitSha",
"worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
@@ -1091,7 +1093,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare(`
INSERT INTO tasks (
id, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha, modelPresetId, modelProvider,
worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
@@ -1102,7 +1104,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -1118,6 +1120,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
paused = excluded.paused,
baseBranch = excluded.baseBranch,
branch = excluded.branch,
executionStartBranch = excluded.executionStartBranch,
baseCommitSha = excluded.baseCommitSha,
modelPresetId = excluded.modelPresetId,
modelProvider = excluded.modelProvider,
@@ -1200,6 +1203,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.paused ? 1 : 0,
task.baseBranch ?? null,
task.branch ?? null,
task.executionStartBranch ?? null,
task.baseCommitSha ?? null,
task.modelPresetId ?? null,
task.modelProvider ?? null,
@@ -2354,8 +2358,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
columnMovedAt: now,
createdAt: now,
updatedAt: now,
// Explicitly NOT copied: worktree, status, blockedBy, paused, baseBranch,
// Explicitly NOT copied: worktree, status, blockedBy, paused, executionStartBranch,
// attachments, comments, prInfo, agent logs, size, reviewLevel
baseBranch: sourceTask.baseBranch,
};
const newDir = this.taskDir(newId);
@@ -2947,7 +2952,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// starts from scratch.
if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) {
task.branch = undefined;
task.baseBranch = undefined;
task.executionStartBranch = undefined;
task.baseCommitSha = undefined;
task.summary = undefined;
task.recoveryRetryCount = undefined;
@@ -2995,7 +3000,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -3123,6 +3128,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.branch !== undefined) {
task.branch = updates.branch;
}
if (updates.executionStartBranch === null) {
task.executionStartBranch = undefined;
} else if (updates.executionStartBranch !== undefined) {
task.executionStartBranch = updates.executionStartBranch;
}
if (updates.baseCommitSha === null) {
task.baseCommitSha = undefined;
} else if (updates.baseCommitSha !== undefined) {
@@ -3997,7 +4007,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
if (deleted.length > 0) {
this.clearStaleBaseBranchReferences(deleted, task.id);
this.clearStaleExecutionStartBranchReferences(deleted, task.id);
}
return deleted;
}
@@ -4015,11 +4025,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
*
* @returns IDs of tasks whose baseBranch was cleared
*/
clearStaleBaseBranchReferences(deletedBranches: string[], ownerTaskId?: string): string[] {
clearStaleExecutionStartBranchReferences(deletedBranches: string[], ownerTaskId?: string): string[] {
if (deletedBranches.length === 0) return [];
const placeholders = deletedBranches.map(() => "?").join(",");
const params: string[] = [...deletedBranches];
let whereClause = `baseBranch IN (${placeholders})`;
let whereClause = `executionStartBranch IN (${placeholders})`;
if (ownerTaskId) {
whereClause += ` AND id != ?`;
params.push(ownerTaskId);
@@ -4030,7 +4040,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (rows.length === 0) return [];
const update = this.db.prepare(
`UPDATE tasks SET baseBranch = NULL, updatedAt = ? WHERE id = ?`,
`UPDATE tasks SET executionStartBranch = NULL, updatedAt = ? WHERE id = ?`,
);
const now = new Date().toISOString();
const clearedIds: string[] = [];
@@ -4040,7 +4050,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (this.isWatching) {
const cached = this.taskCache.get(row.id);
if (cached) {
cached.baseBranch = undefined;
cached.executionStartBranch = undefined;
cached.updatedAt = now;
}
}
@@ -5900,7 +5910,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
planningModelId: entry.planningModelId,
breakIntoSubtasks: entry.breakIntoSubtasks,
modifiedFiles: entry.modifiedFiles,
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, baseCommitSha, error
// Intentionally NOT restoring: worktree, status, blockedBy, paused, executionStartBranch, baseCommitSha, error
};
// Write task.json

View File

@@ -883,19 +883,19 @@ export interface Task {
paused?: boolean;
/** When set, this task was paused because the agent with this ID was paused. Cleared when the agent resumes. Distinct from user-initiated pause. */
pausedByAgentId?: string;
/** Git branch name (or task ID) to use as the starting point when
* creating this task's worktree. Set by the scheduler when a task's
* explicit dependency or `blockedBy` task is in-review with an
* unmerged branch. The executor reads this to branch from the
* dependency's branch instead of HEAD. Cleared after worktree creation. */
/** Configured merge target/base branch for this task (task intent).
* Defaults to the project default branch when omitted. */
baseBranch?: string;
/** Actual git branch name used for this task's worktree. May differ from
/** Actual git working branch name used for this task's worktree. May differ from
* the conventional `fn/{task-id}` when conflict recovery generated a
* unique suffixed name (e.g., `fn/fn-042-2`). The merger and PR systems
* read this field instead of deriving the branch from the task ID. */
* unique suffixed name (e.g., `fn/fn-042-2`). */
branch?: string;
/** Base commit SHA for creating this task's worktree. Used with baseBranch
* to establish the exact starting point for the worktree. */
/** Internal execution-only provenance for dependency-start handoff.
* When set, the scheduler asked executor to start from an upstream dependency
* branch. This is transient execution state and should be cleared after use. */
executionStartBranch?: string;
/** Base commit SHA for creating this task's worktree. Used with the start ref
* chosen for the worktree to establish the exact starting point. */
baseCommitSha?: string;
/** List of files modified by this task (populated during execution) */
modifiedFiles?: string[];

View File

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

View File

@@ -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",
},

View File

@@ -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",

View File

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

View File

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

View File

@@ -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(", ")}`);
}

View File

@@ -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,

View File

@@ -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(", ")}`);
}