diff --git a/.changeset/fn-9161-workspace-custom-branch.md b/.changeset/fn-9161-workspace-custom-branch.md new file mode 100644 index 0000000000..08b8faf496 --- /dev/null +++ b/.changeset/fn-9161-workspace-custom-branch.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Workspace tasks can use one custom branch name across every sub-repository and reuse existing branches. +category: feature +dev: Adds provenance-based task branch ownership, guarded cleanup, task-aware PR heads, collision attach, and identity guards. diff --git a/docs/workspaces.md b/docs/workspaces.md index dd4301e65f..c6b0b9d64a 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -65,6 +65,12 @@ The tool accepts only a configured repository name and returns an isolated, task Fusion adds acquired member paths to the task's active-worktree set, so liveness and ownership checks see the root plus every active member worktree. A live remembered worktree is reused across a resumed task or executor restart. If another task is acquiring the same member, the tool returns a temporary busy error asking the agent to retry `fn_acquire_repo_worktree` shortly; acquire a different member or retry rather than editing the original repository checkout. +### Custom working branches + +In the task form's **Advanced** branch controls, an operator can enter one branch name for a workspace task. Fusion validates the name as a safe Git branch/ref name: it rejects empty or whitespace-padded names, spaces or control characters, `..`, `@{`, a leading `-`, empty path segments, dot-prefixed segments, and trailing `.` or `.lock` segments. Fusion applies the exact valid name in every acquired sub-repository. If the branch already exists in a member repository, Fusion attaches to it without recreating it; it still refuses a branch that is checked out by another live worktree. + +Fusion records whether a branch was written by an operator or by Fusion. An operator-supplied branch is retained after merge, teardown, and recovery, including a name under the `fusion/` namespace, and PR creation uses it as the head branch. Fusion continues to clean up branches it created itself, including canonical `fusion/` branches and entry-point-derived branch-group branches. Ownership follows recorded write provenance, not a branch-name prefix: editing a branch-group task transfers the selected branch to the operator; a later Fusion group reassignment takes ownership back. Shared-group members still work on their canonical task branch. Older tasks without a provenance marker retain their existing behavior. + ## Choosing the base branch Set a task's `baseBranch` in the New Task form or Task Detail to choose the base for a workspace task. At acquisition, Fusion verifies that ref independently in every sub-repository. Where it resolves, it is that worktree's start point, base-SHA anchor, land target, and revert target. Where it does not resolve, Fusion safely falls back to that repository's own integration branch rather than failing acquisition; the requested and selected refs are recorded in the task log and Task Detail, while run audit stores only the task/repository identifiers and fixed decision outcome. diff --git a/packages/cli/src/commands/pr.ts b/packages/cli/src/commands/pr.ts index d7331863dc..bfc16d0ac2 100644 --- a/packages/cli/src/commands/pr.ts +++ b/packages/cli/src/commands/pr.ts @@ -5,6 +5,7 @@ import { isPrEntityActive, isPrEntityActionable, autoMergeGateReason, + resolveTaskPrHeadBranch, type PrEntity, type PrThreadState, } from "@fusion/core"; @@ -299,8 +300,13 @@ export async function runPrCreate(id: string, options: PrCreateOptions = {}, pro process.exit(1); } - // Build branch name using the established project convention - const branchName = `fusion/${id.toLowerCase()}`; + /* + FNXC:WorkspacePrHead 2026-08-20-03:38: + FN-9161's CLI PR command must target the task's real persisted branch. + Workspace tasks can reuse one operator-supplied branch across repositories; + falling back to fusion/ would create or report the wrong PR. + */ + const branchName = resolveTaskPrHeadBranch(task); // Build deterministic fallback PR title const fallbackTitle = options.title diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index b5f396b739..9a3c4d4aba 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -29,6 +29,7 @@ const execFileAsync: (file: string, args: string[], opts?: import("node:child_pr import type { TaskStore } from "@fusion/core"; import { resolveTaskMergeTarget, + resolveTaskPrHeadBranch, getCurrentRepo, getPushRepo, isBranchGroupMemberLanded, @@ -909,7 +910,7 @@ export function createPrNodeGithubOps( sourceType: "task", sourceId: task.id, repo: `${repo.owner}/${repo.repo}`, - headBranch: getTaskBranchName(task.id), + headBranch: resolveTaskPrHeadBranch(task), }; }, createPr: async ({ task, entity, integrationRemote, signal }) => { @@ -917,7 +918,7 @@ export function createPrNodeGithubOps( // Git ops run in the task worktree when known; process.cwd() only as the // single-project fallback. const cwd = options.getTaskWorktree?.(entity.sourceId) ?? task.worktree ?? process.cwd(); - const headBranch = entity.headBranch || getTaskBranchName(task.id); + const headBranch = entity.headBranch || resolveTaskPrHeadBranch(task); const refreshed = await refreshAutomatedPrHead({ projectRoot: cwd, preferredWorktree: task.worktree, @@ -951,7 +952,7 @@ export function createPrNodeGithubOps( const refreshed = await refreshAutomatedPrHead({ projectRoot: cwd, preferredWorktree: task?.worktree, - headBranch: entity.headBranch || getTaskBranchName(task?.id ?? entity.sourceId), + headBranch: entity.headBranch || (task ? resolveTaskPrHeadBranch(task) : getTaskBranchName(entity.sourceId)), targetBranch: entity.baseBranch || "main", integrationRemote, signal, @@ -1301,7 +1302,8 @@ export async function processPullRequestMergeTask( throw new Error("processPullRequestMergeTask: could not determine repository"); } - const branch = getTaskBranchName(task.id); + // FNXC:WorkspacePrHead 2026-08-20-03:38: Pull-request lifecycle git operations must use an explicitly selected task branch, not reconstruct fusion/. + const branch = resolveTaskPrHeadBranch(task); const settings = await store.getSettings(); // `requirePrApproval` MOVED to workflow settings (U4): resolve the task's // effective workflow settings and overlay them onto the project/global base so diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 8b1c17b27e..d0ace4cf88 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1687,7 +1687,7 @@ export async function runTaskRetry(id: string, projectName?: string) { status: null, error: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), @@ -1759,7 +1759,7 @@ export async function runTaskRetry(id: string, projectName?: string) { status: null, error: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, baseBranch: null, baseCommitSha: null, ...autoPauseClearPatch, diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 2037da7b38..ebd0d93ee4 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -2584,7 +2584,7 @@ export default function kbExtension(pi: ExtensionAPI) { status: null, error: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), diff --git a/packages/core/src/__tests__/branch-assignment.test.ts b/packages/core/src/__tests__/branch-assignment.test.ts index c721076457..93ff6c40e7 100644 --- a/packages/core/src/__tests__/branch-assignment.test.ts +++ b/packages/core/src/__tests__/branch-assignment.test.ts @@ -6,6 +6,12 @@ import { sanitizeBranchSegment, isValidBranchGroupBranchName, validateBranchGroupBranchName, + isValidTaskBranchName, + validateTaskBranchName, + classifyTaskBranchOrigin, + isFusionDeletableBranch, + isOperatorAttachEligibleBranch, + resolveTaskPrHeadBranch, filterTasksByBranchGroup, } from "../branch/branch-assignment.js"; @@ -63,6 +69,8 @@ describe("isValidBranchGroupBranchName (Fix #11)", () => { it("validateBranchGroupBranchName throws on invalid and returns valid", () => { expect(validateBranchGroupBranchName("feature/ok")).toBe("feature/ok"); expect(() => validateBranchGroupBranchName("$(touch /tmp/x)")).toThrow(/Invalid branch group branch name/); + expect(isValidTaskBranchName("feature/PRD-1234-my-slug")).toBe(true); + expect(() => validateTaskBranchName("feature/my branch")).toThrow(/Invalid task branch name/); }); }); @@ -115,6 +123,7 @@ describe("branch-assignment", () => { }); expect(assignment).toEqual({ workingBranch: "feature/planning/fn-123-add-parser", + branchWriteOrigin: "engine", mergeTargetBranch: "feature/planning", }); expect(assignment.workingBranch).not.toBe(resolvedBranch); @@ -127,6 +136,7 @@ describe("branch-assignment", () => { taskSegment: " ", })).toEqual({ workingBranch: "feature/planning", + branchWriteOrigin: "engine", mergeTargetBranch: "feature/planning", }); }); @@ -138,6 +148,7 @@ describe("branch-assignment", () => { taskSegment: "FN-123", })).toEqual({ workingBranch: undefined, + branchWriteOrigin: "engine", mergeTargetBranch: undefined, }); }); @@ -149,6 +160,7 @@ describe("branch-assignment", () => { taskSegment: "FN-123 add parser", })).toEqual({ workingBranch: "feature/planning/fn-123-add-parser", + branchWriteOrigin: "engine", mergeTargetBranch: undefined, }); }); @@ -160,6 +172,7 @@ describe("branch-assignment", () => { taskSegment: "FN-123 add parser", })).toEqual({ workingBranch: undefined, + branchWriteOrigin: "engine", mergeTargetBranch: undefined, }); }); @@ -181,3 +194,27 @@ describe("branch-assignment", () => { }); }); }); + +describe("task branch provenance", () => { + it("gives a matching operator marker precedence over the Fusion namespace", () => { + const task = { + id: "FN-123", + branch: "fusion/fn-123", + branchContext: {branchOverride: {by: "operator" as const, at: "2026-08-20T03:40:00.000Z", branch: "fusion/fn-123"}}, + }; + expect(classifyTaskBranchOrigin(task)).toBe("operator-supplied"); + expect(isFusionDeletableBranch(task)).toBe(false); + expect(isOperatorAttachEligibleBranch(task)).toBe(true); + }); + + it("keeps canonical and group-derived branches Fusion-owned", () => { + expect(classifyTaskBranchOrigin({id: "FN-123", branch: "fusion/fn-123"})).toBe("engine-canonical"); + expect(classifyTaskBranchOrigin({id: "FN-123", branch: "feature/onboarding/fn-123", branchContext: {assignmentMode: "per-task-derived"}})).toBe("group-derived"); + expect(classifyTaskBranchOrigin({id: "FN-123", branch: "fusion/fn-999"})).toBe("operator-supplied"); + }); + + it("resolves PR heads from the working branch except shared members", () => { + expect(resolveTaskPrHeadBranch({id: "FN-123", branch: "feature/custom"})).toBe("feature/custom"); + expect(resolveTaskPrHeadBranch({id: "FN-123", branch: "feature/custom", branchContext: {assignmentMode: "shared"}})).toBe("fusion/fn-123"); + }); +}); diff --git a/packages/core/src/__tests__/task-update-awaiting-approval-reason.test.ts b/packages/core/src/__tests__/task-update-awaiting-approval-reason.test.ts index 7c3cf26ddc..fc056d18e6 100644 --- a/packages/core/src/__tests__/task-update-awaiting-approval-reason.test.ts +++ b/packages/core/src/__tests__/task-update-awaiting-approval-reason.test.ts @@ -113,4 +113,12 @@ describe("awaitingApprovalReason survives updateTask", () => { expect(row.status).toBe("awaiting-approval"); expect(row.awaitingApprovalReason).toBe("plan-review-replan-cap"); }); + + it("records an operator marker when an operator reasserts an unmarked current branch", async () => { + const { store, row } = harness({branch: "fusion/fn-1"}); + + await run(store, {branch: "fusion/fn-1", branchWriteOrigin: "operator"}); + + expect(row.branchContext?.branchOverride).toMatchObject({by: "operator", branch: "fusion/fn-1"}); + }); }); diff --git a/packages/core/src/async-stores/async-mission-store.ts b/packages/core/src/async-stores/async-mission-store.ts index f3822fd375..7a2039b468 100644 --- a/packages/core/src/async-stores/async-mission-store.ts +++ b/packages/core/src/async-stores/async-mission-store.ts @@ -3021,6 +3021,7 @@ export class AsyncMissionStore extends EventEmitter { title: taskTitle || feature.title, description, branch: branchAssignment.workingBranch, + ...(branchAssignment.workingBranch ? {branchWriteOrigin: branchAssignment.branchWriteOrigin ?? "engine" as const} : {}), baseBranch: resolvedBaseBranch, ...(missionId ? { diff --git a/packages/core/src/branch/branch-assignment.ts b/packages/core/src/branch/branch-assignment.ts index f85a6ac0c0..72f879c317 100644 --- a/packages/core/src/branch/branch-assignment.ts +++ b/packages/core/src/branch/branch-assignment.ts @@ -8,9 +8,60 @@ export interface EntryPointBranchAssignmentInput { export interface EntryPointBranchAssignment { workingBranch?: string; + /** Set only when this helper derives a Fusion-owned branch. */ + branchWriteOrigin?: "engine"; mergeTargetBranch?: string; } +export type TaskBranchOrigin = "engine-canonical" | "group-derived" | "operator-supplied"; + +/** + * FNXC:BranchNaming 2026-08-20-03:40: + * Branch ownership follows its durable write provenance, not its spelling. A matching + * operator marker wins even for `fusion/`; otherwise only this task's canonical + * namespace is engine-owned, then group assignment marks Fusion-derived branches. + */ +export function classifyTaskBranchOrigin( + task: Pick, + branch?: string, +): TaskBranchOrigin { + const candidate = (branch ?? task.branch ?? `fusion/${task.id.toLowerCase()}`).trim(); + const override = task.branchContext?.branchOverride; + if (override && candidate === override.branch.trim()) return "operator-supplied"; + const escapedId = task.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (new RegExp(`^fusion/${escapedId}(?:-.+)?$`, "i").test(candidate)) return "engine-canonical"; + if (task.branchContext?.assignmentMode === "shared" || task.branchContext?.assignmentMode === "per-task-derived") { + return "group-derived"; + } + return "operator-supplied"; +} + +export function isFusionDeletableBranch( + task: Pick, + branch?: string, +): boolean { + return classifyTaskBranchOrigin(task, branch) !== "operator-supplied"; +} + +export function isOperatorAttachEligibleBranch( + task: Pick, + branch?: string, +): boolean { + return classifyTaskBranchOrigin(task, branch) === "operator-supplied"; +} + +/** + * FNXC:BranchNaming 2026-08-20-03:40: + * PR heads must match the task's real working branch. Shared members keep their + * canonical worktree branch, while all other tasks retain their trimmed branch. + */ +export function resolveTaskPrHeadBranch( + task: Pick, +): string { + if (task.branchContext?.assignmentMode === "shared") return `fusion/${task.id.toLowerCase()}`; + return task.branch?.trim() || `fusion/${task.id.toLowerCase()}`; +} + /** * Conservative git-ref-safe validation for a branch-group branch name, enforced * at the persistence boundary (Fix #11). Branch names flow into shell-adjacent @@ -57,6 +108,16 @@ export function validateBranchGroupBranchName(name: string): string { return name; } +/** General task-branch aliases deliberately share the branch-group ruleset. */ +export const isValidTaskBranchName = isValidBranchGroupBranchName; +export function validateTaskBranchName(name: string): string { + try { + return validateBranchGroupBranchName(name); + } catch { + throw new Error(`Invalid task branch name: ${JSON.stringify(name)}`); + } +} + /** * Pure membership filter shared by `TaskStore.listTasksByBranchGroup` and the * dashboard list route (Fix #8/#9) so the legacy synthetic-groupId fallback @@ -126,16 +187,19 @@ export function resolveEntryPointBranchAssignment( case "shared": return { workingBranch: derivePerTaskBranchName(resolvedBranch, taskSegment), + branchWriteOrigin: "engine", mergeTargetBranch: normalizeOptionalBranch(resolvedBranch), }; case "per-task-derived": return { workingBranch: derivePerTaskBranchName(resolvedBranch, taskSegment), + branchWriteOrigin: "engine", mergeTargetBranch: undefined, }; case "project-default": return { workingBranch: undefined, + branchWriteOrigin: "engine", mergeTargetBranch: undefined, }; case "existing": diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 8d07746232..f33da2d25f 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -54,12 +54,19 @@ export { deriveAutoTaskBranchName, isValidBranchGroupBranchName, validateBranchGroupBranchName, + isValidTaskBranchName, + validateTaskBranchName, + classifyTaskBranchOrigin, + isFusionDeletableBranch, + isOperatorAttachEligibleBranch, + resolveTaskPrHeadBranch, filterTasksByBranchGroup, } from "./branch/branch-assignment.js"; export type { EntryPointAssignmentMode, EntryPointBranchAssignmentInput, EntryPointBranchAssignment, + TaskBranchOrigin, } from "./branch/branch-assignment.js"; export { customProviderRegistryKey } from "./ai/custom-provider-key.js"; export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc117a3382..10b970d86a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -65,12 +65,19 @@ export { deriveAutoTaskBranchName, isValidBranchGroupBranchName, validateBranchGroupBranchName, + isValidTaskBranchName, + validateTaskBranchName, + classifyTaskBranchOrigin, + isFusionDeletableBranch, + isOperatorAttachEligibleBranch, + resolveTaskPrHeadBranch, filterTasksByBranchGroup, } from "./branch/branch-assignment.js"; export type { EntryPointAssignmentMode, EntryPointBranchAssignmentInput, EntryPointBranchAssignment, + TaskBranchOrigin, } from "./branch/branch-assignment.js"; export { customProviderRegistryKey } from "./ai/custom-provider-key.js"; export { diff --git a/packages/core/src/missions/mission-store.ts b/packages/core/src/missions/mission-store.ts index 3f5686f54e..c35d641d62 100644 --- a/packages/core/src/missions/mission-store.ts +++ b/packages/core/src/missions/mission-store.ts @@ -4475,6 +4475,7 @@ export class MissionStore extends EventEmitter { title: taskTitle || feature.title, description, branch: branchAssignment.workingBranch, + ...(branchAssignment.workingBranch ? {branchWriteOrigin: branchAssignment.branchWriteOrigin ?? "engine" as const} : {}), baseBranch: resolvedBaseBranch, ...(missionId ? { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 29df47da64..64ce7e9fe3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1769,7 +1769,7 @@ export class TaskStore extends EventEmitter { } async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | 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; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; recommendations?: import("./types.js").TaskRecommendation[]; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; branchWriteOrigin?: "operator" | "engine"; branchContext?: import("./types.js").TaskBranchContext | 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; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; recommendations?: import("./types.js").TaskRecommendation[]; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, ): Promise { /* FNXC:SpecLock 2026-08-09-20:34: diff --git a/packages/core/src/task-store/branch-context.ts b/packages/core/src/task-store/branch-context.ts index ed400e5e60..8b055e1e81 100644 --- a/packages/core/src/task-store/branch-context.ts +++ b/packages/core/src/task-store/branch-context.ts @@ -20,16 +20,36 @@ export function parseTaskBranchContextFromSourceMetadata(sourceMetadata: Record< const groupId = typeof candidate.groupId === "string" ? candidate.groupId.trim() || undefined : undefined; - if (candidate.source !== "planning" && candidate.source !== "mission" && candidate.source !== "new-task") return undefined; - if (candidate.assignmentMode !== "shared" && candidate.assignmentMode !== "per-task-derived") return undefined; + const source = candidate.source; + const assignmentMode = candidate.assignmentMode; + const hasAssignment = (source === "planning" || source === "mission" || source === "new-task") + && (assignmentMode === "shared" || assignmentMode === "per-task-derived"); + const override = candidate.branchOverride; + const overrideRecord = override && typeof override === "object" && !Array.isArray(override) + ? override as Record + : undefined; + const branchOverride = overrideRecord?.by === "operator" + && typeof overrideRecord.at === "string" + && typeof overrideRecord.branch === "string" + && overrideRecord.branch.trim().length > 0 + ? { + by: "operator" as const, + at: overrideRecord.at, + branch: overrideRecord.branch, + ...(typeof overrideRecord.previousBranch === "string" + ? { previousBranch: overrideRecord.previousBranch } + : {}), + } + : undefined; + if (!hasAssignment && !branchOverride) return undefined; const inheritedBaseBranch = typeof candidate.inheritedBaseBranch === "string" && candidate.inheritedBaseBranch.trim().length > 0 ? candidate.inheritedBaseBranch.trim() : undefined; return { ...(groupId ? { groupId } : {}), - source: candidate.source, - assignmentMode: candidate.assignmentMode, - inheritedBaseBranch, + ...(hasAssignment ? { source, assignmentMode } : {}), + ...(inheritedBaseBranch ? { inheritedBaseBranch } : {}), + ...(branchOverride ? { branchOverride } : {}), }; } @@ -44,9 +64,10 @@ export function withTaskBranchContextInSourceMetadata( ...(branchContext.groupId?.trim() ? { groupId: branchContext.groupId.trim() } : {}), - source: branchContext.source, - assignmentMode: branchContext.assignmentMode, + ...(branchContext.source ? { source: branchContext.source } : {}), + ...(branchContext.assignmentMode ? { assignmentMode: branchContext.assignmentMode } : {}), ...(branchContext.inheritedBaseBranch ? { inheritedBaseBranch: branchContext.inheritedBaseBranch } : {}), + ...(branchContext.branchOverride ? { branchOverride: branchContext.branchOverride } : {}), }, }; } diff --git a/packages/core/src/task-store/merge-queue-ops.ts b/packages/core/src/task-store/merge-queue-ops.ts index 61dd93f300..9842f71437 100644 --- a/packages/core/src/task-store/merge-queue-ops.ts +++ b/packages/core/src/task-store/merge-queue-ops.ts @@ -16,6 +16,7 @@ import {resolveWorkflowIrForTask} from "../workflows/workflow-ir-resolver.js"; import {resolveReviewColumns, resolveTaskLifecycleColumns} from "../workflows/workflow-lifecycle-traits.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {assertSafeGitBranchName, assertSafeAbsolutePath} from "../task-store/shell-safety.js"; +import {isFusionDeletableBranch} from "../branch/branch-assignment.js"; import {acquireMergeQueueLease as acquireMergeQueueLeaseAsync} from "../task-store/async/async-merge-coordination.js"; export type StepStartDisposition = "started" | "resumed" | "blocked" | "terminal"; @@ -382,13 +383,15 @@ export async function mergeTaskImpl(store: TaskStore, id: string): Promise 0 ? branchContext : undefined}; +} + type CreateTaskWithAfterInsert = TaskCreateInput & { /** Internal transaction hook; never persisted in task source metadata. */ afterTaskInsert?: (tx: DbTransaction, task: Task) => Promise; @@ -201,6 +228,7 @@ async function persistDeferredTaskTitleIfUntitled( export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; ownershipExemption?: IntakeOwnershipExemption; },): Promise { /* FNXC:CredentialInstanceSelection 2026-08-01-05:43: validate task authoring input before persistence; ids are stored but runtime credential resolution remains unchanged. */ + input = normalizeCreateBranchProvenance(input); for (const key of ["credentialInstanceId", "validatorCredentialInstanceId", "planningCredentialInstanceId", "mergerCredentialInstanceId"] as const) { const value = (input as unknown as Record)[key]; if (value !== undefined && value !== null) assertValidProviderInstanceId(value); @@ -547,6 +575,7 @@ export class TaskIntakeOwnerResolutionError extends Error { } export async function _createTaskInternalBackendImpl(store: TaskStore, input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; resolvedWorkflowIdForOwnership?: string; onProposalClaimConflict?: (task: Task) => void; deferTaskCreatedEvent?: boolean; onTaskInserted?: (task: Task) => void; ownershipExemption?: IntakeOwnershipExemption; },): Promise { + input = normalizeCreateBranchProvenance(input); const layer = store.asyncLayer!; const now = options?.createdAt ?? new Date().toISOString(); /* @@ -1145,6 +1174,7 @@ export async function createTaskWithReservedIdImpl(store: TaskStore, input: Task } export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; },): Promise { + input = normalizeCreateBranchProvenance(input); const now = options?.createdAt ?? new Date().toISOString(); // FN-5077: null normalized titles are treated as "no title" and allow standard fallback/summarization behavior. const normalizedTitle = normalizeTitleForTaskId(title, id); diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index 1a76cf3a07..415e312c4d 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -28,6 +28,7 @@ import {resolveSameAgentDuplicateIntake} from "./task-creation.js"; import {type TaskRow, TASK_COLUMN_DESCRIPTORS} from "../task-store/persistence.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {assertSafeGitBranchName} from "../task-store/shell-safety.js"; +import {isFusionDeletableBranch} from "../branch/branch-assignment.js"; import {readTaskRow as readTaskRowAsync, readTaskRowInTransaction, resolveActiveTaskWedgeEpisodeRow} from "../task-store/async/async-persistence.js"; import {upsertArchivedTaskEntry} from "./async/async-archive-lineage.js"; import {purgeTaskWorkflowSelectionRowsAsyncImpl} from "./workflow-definitions.js"; @@ -546,7 +547,13 @@ export async function mergeWorkspaceWorktreeEntryImpl( .set({ workspaceWorktrees: { ...workspaceWorktrees, [repoRelPath]: { ...existing, ...patch } }, ...(options.clearSingularWorktree - ? { worktree: null, branch: null, executionStartBranch: null, baseCommitSha: null } + ? { + worktree: null, + branch: null, + branchWriteOrigin: "engine" as const, + executionStartBranch: null, + baseCommitSha: null, + } : {}), updatedAt, }) @@ -974,6 +981,15 @@ export async function cleanupBranchForTaskImpl(store: TaskStore, task: Task): Pr // A malformed stored value should not become a command-injection vector. continue; } + /* + FNXC:BranchDeletionProvenance 2026-08-20-03:39: + A task branch written by an operator remains operator data even when its spelling + resembles Fusion's namespace. Keep the generated fallback only when the shared + provenance classifier proves this candidate is Fusion-owned. + */ + if (!isFusionDeletableBranch(task, branch)) { + continue; + } const verify = await store.runGitCommand(`git rev-parse --verify "${branch}"`); if (verify.exitCode !== 0) { continue; diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 32e48db046..eda2a14937 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -36,6 +36,8 @@ import {hasOwnDeclaredSymbols, normalizeDeclaredSymbols, extractDeclaredSymbolsF import {assertValidProviderInstanceId} from "../provider-instance.js"; import {supersedePlanReviewResults} from "../planner/plan-approval.js"; import {PLAN_REVIEW_GROUP_ID} from "../workflows/builtin-plan-review-group.js"; +import {validateTaskBranchName} from "../branch/branch-assignment.js"; +import {withTaskBranchContextInSourceMetadata} from "./branch-context.js"; /* FNXC:TaskRecommendations 2026-08-08-07:06: @@ -84,6 +86,12 @@ function assertValidRecommendations(value: unknown): asserts value is TaskRecomm export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updates: Parameters[1], runContext?: RunMutationContext,): Promise { /* FNXC:TaskRecommendations 2026-08-08-05:02: every writer, including the recommendation route, shares this authoritative malformed/duplicate-id rejection boundary. */ if (updates.recommendations !== undefined) assertValidRecommendations(updates.recommendations); + if (updates.branch !== undefined) { + if (updates.branchWriteOrigin !== "operator" && updates.branchWriteOrigin !== "engine") { + throw new Error("branchWriteOrigin is required when branch is provided"); + } + if (updates.branch !== null) validateTaskBranchName(updates.branch); + } /* FNXC:CredentialInstanceSelection 2026-08-01-05:43: validate task authoring input before persistence; ids are stored but runtime credential resolution remains unchanged. */ for (const key of ["credentialInstanceId", "validatorCredentialInstanceId", "planningCredentialInstanceId", "mergerCredentialInstanceId"] as const) { const value = (updates as Record)[key]; @@ -610,10 +618,54 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat task.autoMerge = updates.autoMerge; task.autoMergeProvenance = "user"; } - if (updates.branch === null) { - task.branch = undefined; - } else if (updates.branch !== undefined) { - task.branch = updates.branch; + /* + FNXC:BranchNaming 2026-08-20-03:40: + Branch ownership follows this recorded write origin, never name shape. An operator + owns even a `fusion/...` override on a group task; a later engine assignment clears it. + */ + if (updates.branch !== undefined) { + const origin = updates.branchWriteOrigin!; + const nextBranch = updates.branch ?? undefined; + const previousBranch = task.branch; + const changed = nextBranch !== previousBranch; + task.branch = nextBranch; + const nextContext = updates.branchContext === null + ? undefined + : updates.branchContext ?? task.branchContext; + const existingOverride = nextContext?.branchOverride; + const hasMatchingOperatorOverride = existingOverride?.by === "operator" && existingOverride.branch === nextBranch; + if (origin === "operator" && nextBranch && !hasMatchingOperatorOverride) { + task.branchContext = { + ...(nextContext ?? {}), + branchOverride: { + by: "operator", + at: new Date().toISOString(), + branch: nextBranch, + ...(previousBranch && changed ? { previousBranch } : {}), + }, + }; + } else if (origin === "engine") { + const {branchOverride: _override, ...withoutOverride} = nextContext ?? {}; + task.branchContext = Object.keys(withoutOverride).length > 0 ? withoutOverride : undefined; + } else if (updates.branchContext !== undefined) { + task.branchContext = nextContext; + } + task.sourceMetadata = task.branchContext + ? withTaskBranchContextInSourceMetadata(task.sourceMetadata, task.branchContext) + : (() => { + const metadata = {...(task.sourceMetadata ?? {})}; + delete metadata.fusionBranchContext; + return Object.keys(metadata).length > 0 ? metadata : undefined; + })(); + } else if (updates.branchContext !== undefined) { + task.branchContext = updates.branchContext ?? undefined; + task.sourceMetadata = task.branchContext + ? withTaskBranchContextInSourceMetadata(task.sourceMetadata, task.branchContext) + : (() => { + const metadata = {...(task.sourceMetadata ?? {})}; + delete metadata.fusionBranchContext; + return Object.keys(metadata).length > 0 ? metadata : undefined; + })(); } // Keep in sync with the first autoMerge block above; both legacy update // paths may run before persistence. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9c99378f54..39390e1813 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -22,6 +22,11 @@ export type { TaskAgeStalenessLevel, TaskAgeStalenessSignal } from "./tasks/task // dashboard code (whose "@fusion/core" vite alias resolves to types.ts, not the // package barrel) can name the update channel union. export type { UpdateChannel } from "./i18n/app-version.js"; +/* +FNXC:BranchNaming 2026-08-20-03:54: +TaskForm needs the same browser-safe validator as the task store so operator feedback matches the shell-adjacent write boundary. +*/ +export { isValidTaskBranchName } from "./branch/branch-assignment.js"; export { computeCapacityRisk, diff --git a/packages/core/src/types/task/task-core.ts b/packages/core/src/types/task/task-core.ts index b4f24254c8..a15d55e032 100644 --- a/packages/core/src/types/task/task-core.ts +++ b/packages/core/src/types/task/task-core.ts @@ -363,6 +363,18 @@ export type TaskBranchGroupSource = "planning" | "mission" | "new-task"; export type TaskBranchAssignmentMode = "shared" | "per-task-derived"; export interface TaskBranchContext { + /** + * FNXC:BranchNaming 2026-08-20-03:40: + * A branch override records operator ownership at the same write boundary as + * `Task.branch`. It intentionally survives without branch-group fields: an + * operator-owned `fusion/...` name must never be mistaken for Fusion-owned. + */ + branchOverride?: { + by: "operator"; + at: string; + branch: string; + previousBranch?: string; + }; /** * The owning BranchGroup id (`BG-…`). Only set for shared-mode members that * were actually assigned to an ensured branch group. Non-shared members @@ -371,8 +383,10 @@ export interface TaskBranchContext { * synthetic-groupId membership fallback (see filterTasksByBranchGroup). */ groupId?: string; - source: TaskBranchGroupSource; - assignmentMode: TaskBranchAssignmentMode; + /** Omitted for a provenance-only operator override payload. */ + source?: TaskBranchGroupSource; + /** Omitted for a provenance-only operator override payload. */ + assignmentMode?: TaskBranchAssignmentMode; inheritedBaseBranch?: string; } @@ -1491,6 +1505,8 @@ export interface TaskCreateInput { baseBranch?: string; /** Actual git working branch name used for this task's worktree. */ branch?: string; + /** Required with `branch` so durable ownership never has to guess the writer. */ + branchWriteOrigin?: "operator" | "engine"; /** Optional planning/mission branch-group metadata carried across related tasks. */ branchContext?: TaskBranchContext; /** diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 4bc9511b49..137ffa47dc 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -2,8 +2,15 @@ import "./NewTaskModal.css"; import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { DEFAULT_TASK_PRIORITY, type ColumnId, type Task, type TaskPriority, type ThinkingLevel } from "@fusion/core"; -import { getErrorMessage } from "@fusion/core"; +import { + DEFAULT_TASK_PRIORITY, + getErrorMessage, + isValidTaskBranchName, + type ColumnId, + type Task, + type TaskPriority, + type ThinkingLevel, +} from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { apiFetchGitHubIssues, @@ -564,7 +571,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const githubRepoOverrideTrimmed = githubRepoOverride.trim(); const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed); const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group"; - const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim(); + /* + FNXC:WorkspaceBranchInput 2026-08-20-03:38: + FN-9161 accepts an operator branch for workspace reuse, but the dashboard must + reject malformed refs before it submits the create request. Match core's write + boundary predicate so the client help and server outcome cannot drift. + */ + const hasInvalidBranchSelection = isBranchNameRequired && (!branch.trim() || !isValidTaskBranchName(branch.trim())); const resolvedStartWorkflowId = selectedWorkflowId === null ? null @@ -1243,7 +1256,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, {hasInvalidBranchSelection && ( -
{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}
+
+ {!branch.trim() + ? t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.") + : t("newTaskModal.branchInvalid", "Enter a valid Git branch name (no spaces or ref punctuation).")} +
)}
diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index 29d816ca23..045fcb6033 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useComposerDictation } from "../hooks/useComposerDictation"; import { MicButton } from "./MicButton"; -import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition, type ResolvedWorkflowOptionalStep } from "@fusion/core"; +import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, isValidTaskBranchName, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition, type ResolvedWorkflowOptionalStep } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { fetchModels, fetchSettings, fetchWorkflows, fetchWorkflowOptionalSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api"; import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown"; @@ -288,6 +288,15 @@ export function TaskForm({ onGithubRepoOverrideChange, }: TaskFormProps) { const { t } = useTranslation("app"); + const branchNameRequired = branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group"; + /* + FNXC:WorkspaceBranchInput 2026-08-20-03:38: + FN-9161 exposes a reusable workspace branch in this shared form. Show the + core-validity result beside the field so operators can correct a ref before + New Task blocks submission at its matching client-side validation boundary. + */ + const trimmedBranchName = (branch ?? "").trim(); + const branchNameInvalid = branchNameRequired && trimmedBranchName !== "" && !isValidTaskBranchName(trimmedBranchName); const hasInitialMoreOptions = (hideDependencies ? false : dependencies.length > 0) || pendingImages.length > 0 || @@ -1425,8 +1434,15 @@ export function TaskForm({ value={branch || ""} onChange={(e) => onBranchChange(e.target.value)} placeholder={branchMode === "shared-group" ? t("taskForm.sharedBranchPlaceholder", "e.g. clionboarding") : t("taskForm.branchPlaceholder", "e.g. feature/my-task")} + aria-invalid={branchNameInvalid || undefined} + aria-describedby={branchNameInvalid ? "task-working-branch-help" : undefined} disabled={disabled} /> + {branchNameInvalid && ( +
+ {t("taskForm.branchNameInvalid", "Enter a valid Git branch name (no spaces or ref punctuation).")} +
+ )} )} {onBaseBranchChange && ( diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 73e3be1aae..92d3082b47 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -1216,6 +1216,18 @@ describe("NewTaskModal", () => { expect(props.onCreateTask).not.toHaveBeenCalled(); }); + it("rejects an invalid custom branch name before submitting", () => { + const { props } = renderNewTaskModal(); + + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with malformed branch" } }); + fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } }); + fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: "feature/has space" } }); + + expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled(); + expect(screen.getAllByText("Enter a valid Git branch name (no spaces or ref punctuation).").length).toBeGreaterThan(0); + expect(props.onCreateTask).not.toHaveBeenCalled(); + }); + it("submits custom-new branch selection when branch name exists", async () => { const { props } = renderNewTaskModal(); diff --git a/packages/dashboard/src/pr-conflict-resolver.ts b/packages/dashboard/src/pr-conflict-resolver.ts index 6ec6b97a97..5138d6c953 100644 --- a/packages/dashboard/src/pr-conflict-resolver.ts +++ b/packages/dashboard/src/pr-conflict-resolver.ts @@ -1,7 +1,7 @@ import { access, mkdir, readFile, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { Settings, TaskStore } from "@fusion/core"; -import { resolveProjectDefaultModel } from "@fusion/core"; +import { resolveProjectDefaultModel, resolveTaskPrHeadBranch } from "@fusion/core"; import { createResolvedAgentSession, resolveMcpServersForStore, type PluginRunner } from "@fusion/engine"; import { runGitCommand } from "./routes/resolve-diff-base.js"; @@ -53,10 +53,6 @@ export interface ResolvePrConflictsResult { message: string; } -function getHeadBranch(taskId: string): string { - return `fusion/${taskId.toLowerCase()}`; -} - /* FNXC:LaneModelResolution 2026-07-24-17:40: Delegate to the shared core resolver instead of hand-rolling the override→default chain. @@ -223,7 +219,13 @@ async function runResolutionAgent(params: { export async function resolvePrConflicts(input: ResolvePrConflictsInput): Promise { const { taskId, baseRef, rootDir, store } = input; const task = await store.getTask(taskId); - const branchName = getHeadBranch(taskId); + /* + FNXC:WorkspacePrHead 2026-08-20-03:38: + Conflict resolution checks out the same persisted branch PR creation exposes. + A workspace task may intentionally reuse an operator branch, so deriving + fusion/ here would resolve conflicts on the wrong ref. + */ + const branchName = resolveTaskPrHeadBranch(task); const reusableWorktree = await resolveUsableWorktree(task.worktree, branchName); const tempWorktreePath = join(rootDir, ".fusion", "worktrees", `conflict-${taskId.toLowerCase()}`); const cwd = reusableWorktree ?? tempWorktreePath; diff --git a/packages/dashboard/src/routes/branch-selection.ts b/packages/dashboard/src/routes/branch-selection.ts index fe0a7f7c5c..abc6d6783f 100644 --- a/packages/dashboard/src/routes/branch-selection.ts +++ b/packages/dashboard/src/routes/branch-selection.ts @@ -3,6 +3,7 @@ import { derivePerTaskBranchName, resolveEntryPointBranchAssignment, sanitizeBranchSegment, + isValidTaskBranchName, } from "@fusion/core"; import type { EntryPointAssignmentMode, @@ -115,6 +116,9 @@ export function resolveBranchSelection( if (!branchName) { throw badRequest("branchSelection.branchName is required for shared-group mode"); } + if (!isValidTaskBranchName(branchName)) { + throw badRequest(`Invalid branch name: ${JSON.stringify(branchName)}`); + } return { branch: undefined, baseBranch, @@ -125,6 +129,9 @@ export function resolveBranchSelection( if (!branchName) { throw badRequest("branchSelection.branchName is required for existing/custom-new modes"); } + if (!isValidTaskBranchName(branchName)) { + throw badRequest(`Invalid branch name: ${JSON.stringify(branchName)}`); + } return { branch: branchName, diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index e38da7fad4..9f0d390cd8 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -1,4 +1,4 @@ -import { createLogger, createIngestedCheckResolver, resolveRequiredCheckNames, resolveWorkflowIrForTask, resolveReviewColumns, resolveReboundTarget } from "@fusion/core"; +import { createLogger, createIngestedCheckResolver, resolveRequiredCheckNames, resolveWorkflowIrForTask, resolveReviewColumns, resolveReboundTarget, resolveTaskPrHeadBranch } from "@fusion/core"; const severityAuditLog = createLogger("dashboard-register-git-github"); import { type NextFunction, type Request, type Response } from "express"; @@ -414,7 +414,13 @@ async function computePrPreflight(task: Task, repoRoot: string, requestedBase?: const defaultBaseBranch = requestedBase?.trim() ? ensureSafeGitRef(requestedBase, "base branch") : await resolveDefaultPrBaseBranch(task, repoRoot); - const head = `fusion/${task.id.toLowerCase()}`; + /* + FNXC:WorkspacePrHead 2026-08-20-03:38: + FN-9161 lets workspace tasks use one operator-supplied branch in every repository. + PR preflight must inspect that persisted working branch rather than inventing the + legacy task-derived ref, or a valid workspace branch appears absent. + */ + const head = resolveTaskPrHeadBranch(task); const safeHead = ensureSafeGitRef(head, "head branch"); const response: PrPreflightResponse = { branchOnRemote: false, @@ -5477,8 +5483,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { const existingPrs = getTaskPrList(task); - // Determine branch name from task - const branchName = `fusion/${task.id.toLowerCase()}`; + // FNXC:WorkspacePrHead 2026-08-20-03:38: PR creation follows the task's persisted working branch, including an operator-supplied workspace branch. + const branchName = resolveTaskPrHeadBranch(task); // Get owner/repo from git remote or GITHUB_REPOSITORY env let owner: string; @@ -5580,7 +5586,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { const requestedBase = typeof req.body?.base === "string" ? req.body.base.trim() : ""; const defaultBaseBranch = requestedBase || await resolveDefaultPrBaseBranch(task, repoRoot); const baseBranch = ensureSafeGitRef(defaultBaseBranch, "base branch"); - const head = ensureSafeGitRef(`fusion/${task.id.toLowerCase()}`, "head branch"); + const head = ensureSafeGitRef(resolveTaskPrHeadBranch(task), "head branch"); const headRef = `refs/heads/${head}`; const baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch); @@ -5661,7 +5667,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { const requestedBase = typeof req.body?.base === "string" ? req.body.base.trim() : ""; const defaultBaseBranch = requestedBase || await resolveDefaultPrBaseBranch(task, repoRoot); const baseBranch = ensureSafeGitRef(defaultBaseBranch, "base branch"); - const head = ensureSafeGitRef(`fusion/${task.id.toLowerCase()}`, "head branch"); + const head = ensureSafeGitRef(resolveTaskPrHeadBranch(task), "head branch"); const baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch); /* diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 344555fa34..b1c45e46a2 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -131,7 +131,7 @@ import { buildBoardWorkflowsPayload } from "./board-workflows.js"; import { resolveNativeStructurePreview } from "../native-structure-preview.js"; import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js"; import { computePlanApprovalFingerprint, isTaskAwaitingPlanning, isWorkspaceTask, type RunAuditEventInput } from "@fusion/core"; -import { FUSION_CLIENT_HEADER, resolveHttpDeleteCallerKind } from "@fusion/core"; +import { FUSION_CLIENT_HEADER, resolveHttpDeleteCallerKind, isValidTaskBranchName } from "@fusion/core"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; // FNXC:TaskLookup404 2026-07-26-11:40: shared task-miss -> 404 mapping seam. import { isTaskLookupMiss, rethrowTaskApiError } from "./task-lookup-error.js"; @@ -2137,6 +2137,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork }, }, branch: normalizedBranch, + ...(normalizedBranch ? { branchWriteOrigin: "operator" as const } : {}), baseBranch: normalizedBaseBranch, ...(typeof nodeId === "string" && nodeId.trim().length > 0 ? { nodeId: nodeId.trim() } : {}), ...(validatedGithubTracking ? { githubTracking: validatedGithubTracking } : {}), @@ -2188,6 +2189,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork task.id, (((task.title ?? "").trim() || task.description).slice(0, 60)), ), + branchWriteOrigin: "engine", }) : task; @@ -2198,7 +2200,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork await scopedStore.setTaskBranchGroup(taskWithAutoBranch.id, group.id); const taskSegment = ((taskWithAutoBranch.title ?? "").trim() || taskWithAutoBranch.description).slice(0, 60); const workingBranch = derivePerTaskBranch(sharedFeatureBranch, taskSegment); - return scopedStore.updateTask(taskWithAutoBranch.id, { branch: workingBranch }); + return scopedStore.updateTask(taskWithAutoBranch.id, { branch: workingBranch, branchWriteOrigin: "engine" }); })() : taskWithAutoBranch; @@ -3464,6 +3466,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork error: null, worktree: null, branch: null, + branchWriteOrigin: "engine", sessionFile: null, ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), @@ -3523,6 +3526,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork error: null, worktree: null, branch: null, + branchWriteOrigin: "engine", baseBranch: null, baseCommitSha: null, ...autoPauseClearPatch, @@ -6124,6 +6128,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork throw new Error(`${fieldName} must be a string or null`); } const trimmed = value.trim(); + if (trimmed.length > 0 && !isValidTaskBranchName(trimmed)) { + throw badRequest(`Invalid branch name: ${JSON.stringify(value)}`); + } return trimmed.length > 0 ? trimmed : null; }; @@ -6331,7 +6338,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } if (hasBodyField("sourceIssue")) updates.sourceIssue = validatedSourceIssue === undefined ? undefined : validatedSourceIssue; if (hasBodyField("nodeId")) updates.nodeId = validatedNodeId; - if (hasBodyField("branch")) updates.branch = normalizedBranch; + if (hasBodyField("branch")) { + updates.branch = normalizedBranch; + // A clear is an operator branch mutation too; the store requires explicit provenance for every branch write. + updates.branchWriteOrigin = "operator"; + } if (hasBodyField("baseBranch")) updates.baseBranch = normalizedBaseBranch; if (hasBodyField("githubTracking")) { (updates as Record).githubTracking = validatedGithubTracking; diff --git a/packages/engine/src/__tests__/worktree-hooks.test.ts b/packages/engine/src/__tests__/worktree-hooks.test.ts index 7eb3cb667a..5871be0cb6 100644 --- a/packages/engine/src/__tests__/worktree-hooks.test.ts +++ b/packages/engine/src/__tests__/worktree-hooks.test.ts @@ -32,6 +32,12 @@ describe("worktree-hooks", () => { expect(hook).toContain(`EXPECTED_BRANCH=\"${expectedBranch}\"`); }); + it("uses an operator-selected working branch when supplied", () => { + const hook = buildIdentityGuardHook("FN-9161", undefined, "feature/PRD-1234-my-slug"); + + expect(hook).toContain('EXPECTED_BRANCH="feature/PRD-1234-my-slug"'); + }); + it("honors the merger bypass marker on detached HEAD before computing EXPECTED_BRANCH", () => { const hook = buildIdentityGuardHook("FN-5483"); const bypassIndex = hook.indexOf('FUSION_MERGER_BYPASS_IDENTITY_GUARD:-'); diff --git a/packages/engine/src/__tests__/worktree-pool.test.ts b/packages/engine/src/__tests__/worktree-pool.test.ts index aa07bc14ee..b4e467fcd5 100644 --- a/packages/engine/src/__tests__/worktree-pool.test.ts +++ b/packages/engine/src/__tests__/worktree-pool.test.ts @@ -277,6 +277,14 @@ describe("WorktreePool", () => { expect(cleanupOrder).toBeLessThan(detachCallOrder); }); + it("attaches an existing operator branch without force-resetting it", async () => { + await pool.prepareForTask("/tmp/wt", "feature/PRD-1234-my-slug", undefined, { branchOrigin: "operator-supplied" }); + + const calls = mockedExecSync.mock.calls.map(([command]) => command); + expect(calls).toContain('git checkout "feature/PRD-1234-my-slug"'); + expect(calls).not.toContain('git checkout -B "feature/PRD-1234-my-slug" main'); + }); + it("creates branch from main with force-reset", async () => { await pool.prepareForTask("/tmp/wt", "fusion/fn-042"); diff --git a/packages/engine/src/auto-recovery-handlers/branch-worktree.ts b/packages/engine/src/auto-recovery-handlers/branch-worktree.ts index 1d25388c0e..424182750c 100644 --- a/packages/engine/src/auto-recovery-handlers/branch-worktree.ts +++ b/packages/engine/src/auto-recovery-handlers/branch-worktree.ts @@ -2,7 +2,7 @@ import { exec } from "node:child_process"; import { existsSync } from "node:fs"; import { promisify } from "node:util"; import type { Task, TaskStore } from "@fusion/core"; -import { resolveWorkflowIrForTask, columnsWithFlag, resolveReboundTarget, TransitionRejectionError } from "@fusion/core"; +import { isFusionDeletableBranch, resolveWorkflowIrForTask, columnsWithFlag, resolveReboundTarget, TransitionRejectionError } from "@fusion/core"; import { classifyBootstrapMisbinding, inspectBranchConflict, @@ -237,7 +237,7 @@ export class BranchWorktreeAutoRecoveryHandler { } if (wipColumns.has(task.column)) { - await this.deps.taskStore.updateTask(task.id, { branch: null, baseCommitSha: null }); + await this.deps.taskStore.updateTask(task.id, { branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null }); } await this.deps.runAudit.database({ type: "branch-worktree:auto-requeue", @@ -404,12 +404,16 @@ export class BranchWorktreeAutoRecoveryHandler { // best-effort } try { - await execAsync(`git branch -D ${this.quote(branchName)}`, { - cwd: repoDir, - timeout: GIT_TIMEOUT_MS, - maxBuffer: GIT_MAX_BUFFER, - }); - branchDeleted = true; + if (!isFusionDeletableBranch(ctx.task, branchName)) { + this.logger.log(`Kept operator-supplied branch ${branchName}`); + } else { + await execAsync(`git branch -D ${this.quote(branchName)}`, { + cwd: repoDir, + timeout: GIT_TIMEOUT_MS, + maxBuffer: GIT_MAX_BUFFER, + }); + branchDeleted = true; + } } catch (err) { this.logger.warn(`FN-4847 discard: branch -D failed for ${branchName}: ${err instanceof Error ? err.message : String(err)}`); } diff --git a/packages/engine/src/execution/step-session-executor.ts b/packages/engine/src/execution/step-session-executor.ts index 034fd16255..199bc3231c 100644 --- a/packages/engine/src/execution/step-session-executor.ts +++ b/packages/engine/src/execution/step-session-executor.ts @@ -19,7 +19,7 @@ import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; import type { AgentHeartbeatRun, AgentStore, MessageStore, PermanentAgentGatingContext, ProviderInstanceRef, ResolvedMcpServerDefinition, TaskDetail, Settings, SteeringComment, TaskStore } from "@fusion/core"; -import { isValidProviderInstanceId, resolvePersistAgentThinkingLog, resolveExecutorFallbackModel } from "@fusion/core"; +import { isFusionDeletableBranch, isValidProviderInstanceId, resolvePersistAgentThinkingLog, resolveExecutorFallbackModel } from "@fusion/core"; import { createResolvedAgentSession, @@ -1096,6 +1096,7 @@ export class StepSessionExecutor { // Delete branches created for parallel worktrees for (const [stepIdx, branchName] of this.parallelBranches) { try { + if (!isFusionDeletableBranch(this.options.taskDetail, branchName)) continue; await execAsync(`git branch -D "${branchName}"`, { cwd: this.options.rootDir, }); @@ -1844,7 +1845,7 @@ Follow instructions precisely and avoid unrelated changes.`, }); } const branch = this.parallelBranches.get(stepIdx); - if (branch) { + if (branch && isFusionDeletableBranch(this.options.taskDetail, branch)) { await execAsync(`git branch -D "${branch}"`, { cwd: this.options.rootDir, }); @@ -1903,6 +1904,7 @@ Follow instructions precisely and avoid unrelated changes.`, await installTaskWorktreeIdentityGuard({ worktreePath, taskId: this.options.taskDetail.id, + expectedBranch: branchName, commitMsgHookEnabled: settings.commitMsgHookEnabled, taskPrefix: settings.taskPrefix, taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], diff --git a/packages/engine/src/executor/build-foreach-worktree-deps.ts b/packages/engine/src/executor/build-foreach-worktree-deps.ts index f96043f366..85a635a31e 100644 --- a/packages/engine/src/executor/build-foreach-worktree-deps.ts +++ b/packages/engine/src/executor/build-foreach-worktree-deps.ts @@ -8,7 +8,7 @@ * integration rebases each branch in step order; projection flips done-iff-integrated. * Best-effort: a git failure routes the foreach to a clean failure rather than crashing the run. */ -import { isWorkspaceTask, type Task, type TaskStore } from "@fusion/core"; +import { isFusionDeletableBranch, isWorkspaceTask, type Task, type TaskStore } from "@fusion/core"; import { exec } from "node:child_process"; import { promisify } from "node:util"; import { getConflictedFiles } from "../merger.js"; @@ -213,7 +213,8 @@ export function buildForeachWorktreeDeps( } instancePaths.delete(stepIndex); } - // Delete the (now-merged or conflicting) branch. + // Delete only branches proven to originate from Fusion's task assignment. + if (!isFusionDeletableBranch(task, branchName)) return; try { await execAsync(`git branch -D ${branchName}`, { cwd }); } catch { diff --git a/packages/engine/src/executor/create-task-done-tool.ts b/packages/engine/src/executor/create-task-done-tool.ts index bd05874bb5..457ed1319e 100644 --- a/packages/engine/src/executor/create-task-done-tool.ts +++ b/packages/engine/src/executor/create-task-done-tool.ts @@ -319,7 +319,7 @@ export function createTaskDoneTool( paused: false, pausedByAgentId: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, }); await store.logEntry( @@ -337,7 +337,7 @@ export function createTaskDoneTool( paused: false, pausedByAgentId: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, }); await store.logEntry(taskId, `${refusalMessage} — invariant-check retry budget exhausted`, undefined, deps.getRunContextFor(task.id)); @@ -373,7 +373,7 @@ export function createTaskDoneTool( paused: false, pausedByAgentId: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, }); await store.logEntry( @@ -392,7 +392,7 @@ export function createTaskDoneTool( paused: false, pausedByAgentId: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, }); await store.logEntry(taskId, `${refusalMessage} — fn_task_done refusal retry budget exhausted`, undefined, deps.getRunContextFor(task.id)); diff --git a/packages/engine/src/executor/dep-abort-cleanup.ts b/packages/engine/src/executor/dep-abort-cleanup.ts index 32a84f19c5..caac36f39f 100644 --- a/packages/engine/src/executor/dep-abort-cleanup.ts +++ b/packages/engine/src/executor/dep-abort-cleanup.ts @@ -5,7 +5,7 @@ */ import { exec } from "node:child_process"; import { promisify } from "node:util"; -import type { Settings, TaskStore } from "@fusion/core"; +import { isFusionDeletableBranch, type Settings, type TaskStore } from "@fusion/core"; import { resolveTaskWorkingBranch } from "../worktree/worktree-names.js"; import { RemovalReason } from "../worktree/worktree-pool.js"; import { executorLog } from "../logger.js"; @@ -58,7 +58,7 @@ export async function handleDepAbortCleanup( // Delete only a Fusion-managed branch. External routes remain operator-owned. const branch = resolveTaskWorkingBranch(task); let branchDeleted = false; - if (!externalExecutionRoute.configured) { + if (!externalExecutionRoute.configured && isFusionDeletableBranch(task, branch)) { try { await execAsync(`git branch -D "${branch}"`, { cwd: deps.rootDir }); branchDeleted = true; diff --git a/packages/engine/src/executor/mark-stuck-aborted.ts b/packages/engine/src/executor/mark-stuck-aborted.ts index 702d7a7537..78c9d1e69a 100644 --- a/packages/engine/src/executor/mark-stuck-aborted.ts +++ b/packages/engine/src/executor/mark-stuck-aborted.ts @@ -172,7 +172,7 @@ export function markStuckAborted( status: "queued", error: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, }); await deps.store.moveTask(taskId, await resolveReboundColumnFor(deps.store, taskId), preserveProgress ? { preserveProgress: true } : undefined); // Remove from executing only after the hung surfaces and worktree have diff --git a/packages/engine/src/executor/release-pre-execution-worktree.ts b/packages/engine/src/executor/release-pre-execution-worktree.ts index 523a7bd94c..aa7c53bd8d 100644 --- a/packages/engine/src/executor/release-pre-execution-worktree.ts +++ b/packages/engine/src/executor/release-pre-execution-worktree.ts @@ -56,7 +56,7 @@ export async function releasePreExecutionWorktree( }); } deps.activeWorktrees.get(taskId)?.delete(live.worktree); - await deps.store.updateTask(taskId, { worktree: null, branch: null, baseCommitSha: null, sessionFile: null }, deps.getRunContextFor(taskId)); + await deps.store.updateTask(taskId, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null, sessionFile: null }, deps.getRunContextFor(taskId)); await deps.store.logEntry(taskId, `Released the pre-execution worktree (${reason}) — it will be re-acquired when planning or execution resumes`, undefined, deps.getRunContextFor(taskId)).catch(() => undefined); executorLog.log(`${taskId}: released pre-execution worktree ${live.worktree} (${reason})`); return true; diff --git a/packages/engine/src/executor/run-implementation.ts b/packages/engine/src/executor/run-implementation.ts index a998abe280..f6b5ecf0bf 100644 --- a/packages/engine/src/executor/run-implementation.ts +++ b/packages/engine/src/executor/run-implementation.ts @@ -941,7 +941,7 @@ export async function runImplementation( status: "queued", error: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, taskDoneRetryCount: nextRequeueCount, paused: false, @@ -961,7 +961,7 @@ export async function runImplementation( status: "failed", error: failureMessage, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, paused: false, pausedByAgentId: null, @@ -1585,7 +1585,7 @@ export async function runImplementation( recoveryRetryCount: decision.nextState.recoveryRetryCount, nextRecoveryAt: decision.nextState.nextRecoveryAt, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, }); deps.markGraphExecuteSelfRequeued(task.id); await deps.store.moveTask(task.id, await resolveReboundColumnFor(deps.store, task.id), { preserveProgress: true }); @@ -1698,7 +1698,7 @@ export async function runImplementation( status: "queued", error: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, }); const reboundColumn = await resolveReboundColumnFor(deps.store, task.id); if (latestTask.column !== reboundColumn) { @@ -2865,7 +2865,7 @@ export async function runImplementation( // Clear any stale binding so the next pickup creates a fresh worktree. // baseCommitSha is also cleared because it pinned to the now-reclaimed worktree; // the next pickup will re-anchor it on the fresh checkout. - await deps.store.updateTask(task.id, { worktree: null, branch: null, baseCommitSha: null }); + await deps.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null }); await deps.persistTokenUsage(task.id); deps.markGraphExecuteSelfRequeued(task.id); await deps.store.moveTask(task.id, await resolveReboundColumnFor(deps.store, task.id), { preserveProgress: true }); @@ -3716,7 +3716,7 @@ export async function runImplementation( recoveryRetryCount: decision.nextState.recoveryRetryCount, nextRecoveryAt: decision.nextState.nextRecoveryAt, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, }); deps.markGraphExecuteSelfRequeued(task.id); await deps.store.moveTask(task.id, await resolveReboundColumnFor(deps.store, task.id), { preserveProgress: true }); @@ -3922,7 +3922,7 @@ export async function runImplementation( status: "queued", error: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, }); // Only move to todo if not already there. Use the freshly-read // latestTask.column rather than the stale captured task.column — diff --git a/packages/engine/src/executor/task-done-refusal-handler.ts b/packages/engine/src/executor/task-done-refusal-handler.ts index f70283df34..a3aff8c26e 100644 --- a/packages/engine/src/executor/task-done-refusal-handler.ts +++ b/packages/engine/src/executor/task-done-refusal-handler.ts @@ -42,7 +42,7 @@ export async function handleImplicitTaskDoneRefusal( paused: false, pausedByAgentId: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, }); await deps.store.logEntry( @@ -61,7 +61,7 @@ export async function handleImplicitTaskDoneRefusal( paused: false, pausedByAgentId: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, }); await deps.store.logEntry(task.id, `${refusal.message} — execution failed because implicit fn_task_done was refused`, undefined, deps.getRunContextFor(task.id)); diff --git a/packages/engine/src/executor/worktree-branch-conflict-handle.ts b/packages/engine/src/executor/worktree-branch-conflict-handle.ts index 4c46038132..01ad0c090c 100644 --- a/packages/engine/src/executor/worktree-branch-conflict-handle.ts +++ b/packages/engine/src/executor/worktree-branch-conflict-handle.ts @@ -5,7 +5,7 @@ */ import { exec } from "node:child_process"; import { promisify } from "node:util"; -import type { Settings, Task, TaskStore } from "@fusion/core"; +import { isFusionDeletableBranch, type Settings, type Task, type TaskStore } from "@fusion/core"; import { assertCleanBranchAtBase, BranchConflictError, @@ -96,7 +96,7 @@ export async function handleBranchConflict( }); if (inspection.kind === "stale-resolved") { - await deps.store.updateTask(task.id, { worktree: null, branch: null, baseCommitSha: null }); + await deps.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null }); const message = `[recovery] ${task.id} stage-A: pruned stale admin entry for ${error.branchName}`; await deps.store.logEntry(task.id, message, undefined, deps.getRunContextFor(task.id)); await deps.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "status", message, "executor"); @@ -116,7 +116,7 @@ export async function handleBranchConflict( } catch { // best-effort } - try { + if (isFusionDeletableBranch(task, error.branchName)) try { await execAsync(`git branch -D ${JSON.stringify(error.branchName)}`, { cwd: deps.rootDir, timeout: 120_000, @@ -125,7 +125,7 @@ export async function handleBranchConflict( } catch { // best-effort } - await deps.store.updateTask(task.id, { worktree: null, branch: null, baseCommitSha: null }); + await deps.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null }); const message = `[recovery] ${task.id} stage-A: tip-already-merged cleanup for ${error.branchName} (${inspection.tipSha.slice(0, 12)} on ${inspection.integrationRef})`; await deps.store.logEntry(task.id, message, undefined, deps.getRunContextFor(task.id)); await deps.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "status", message, "executor"); @@ -152,7 +152,7 @@ export async function handleBranchConflict( } try { const worktreeMap = await getWorktreeBranchMap(deps.rootDir); - if (!worktreeMap.has(error.branchName)) { + if (!worktreeMap.has(error.branchName) && isFusionDeletableBranch(task, error.branchName)) { await execAsync(`git branch -D "${error.branchName}"`, { cwd: deps.rootDir }); } } catch { diff --git a/packages/engine/src/executor/worktree-cleanup-conflicting.ts b/packages/engine/src/executor/worktree-cleanup-conflicting.ts index 62ca27798c..e95dfb6d24 100644 --- a/packages/engine/src/executor/worktree-cleanup-conflicting.ts +++ b/packages/engine/src/executor/worktree-cleanup-conflicting.ts @@ -7,7 +7,7 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, lstatSync, realpathSync } from "node:fs"; import { rm } from "node:fs/promises"; -import type { Settings } from "@fusion/core"; +import { isFusionDeletableBranch, type Settings, type Task } from "@fusion/core"; import { isInsideWorktreesDir, isRegisteredGitWorktree, @@ -23,6 +23,7 @@ export type CleanupConflictingWorktreeDeps = { logEntry: (taskId: string, action: string, outcome?: string) => Promise; getSettings: () => Promise; clearStaleExecutionStartBranchReferences: (branches: string[], excludingTaskId?: string) => Promise; + getTask?: (taskId: string) => Promise; }; reconcileSelfOwnedBeforeRemove: (worktreePath: string, taskId: string) => Promise; findActiveWorktreeOwner: (worktreePath: string, requestingTaskId: string) => Promise; @@ -55,6 +56,9 @@ export async function cleanupConflictingWorktree( return false; } + // Fail closed when this narrow cleanup facade cannot prove branch provenance. + const task = await deps.store.getTask?.(taskId); + try { // Check if worktree is locked and unlock if needed try { @@ -77,17 +81,18 @@ export async function cleanupConflictingWorktree( }); await deps.store.logEntry(taskId, `Removed conflicting worktree`, worktreePath); - // Delete the branch if it exists - try { - await execAsync(`git branch -D "${branch}"`, { - cwd: deps.rootDir, - }); - await deps.store.logEntry(taskId, `Deleted branch`, branch); - // FN-2165 regression guard: null baseBranch on any task that stored this branch - await deps.store.clearStaleExecutionStartBranchReferences([branch], taskId); - } catch (err: unknown) { + if (task && isFusionDeletableBranch(task, branch)) { + try { + await execAsync(`git branch -D "${branch}"`, { + cwd: deps.rootDir, + }); + await deps.store.logEntry(taskId, `Deleted branch`, branch); + // FN-2165 regression guard: null baseBranch on any task that stored this branch + await deps.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}`); + executorLog.warn(`${taskId}: failed to delete conflicting branch ${branch}: ${msg}`); + } } return true; @@ -170,11 +175,13 @@ export async function cleanupConflictingWorktree( executorLog.warn(`${taskId}: failed to remove orphan worktree directory ${worktreePath}: ${rmMsg}`); } } - try { - await execAsync(`git branch -D "${branch}"`, { cwd: deps.rootDir }); - await deps.store.clearStaleExecutionStartBranchReferences([branch], taskId); - } catch { - // best-effort — branch may not exist, which is fine for a stale-path cleanup + if (task && isFusionDeletableBranch(task, branch)) { + try { + await execAsync(`git branch -D "${branch}"`, { cwd: deps.rootDir }); + await deps.store.clearStaleExecutionStartBranchReferences([branch], taskId); + } catch { + // best-effort — branch may not exist, which is fine for a stale-path cleanup + } } await deps.store.logEntry( taskId, diff --git a/packages/engine/src/executor/worktree-create-conflict.ts b/packages/engine/src/executor/worktree-create-conflict.ts index 8ad24d5012..7577143051 100644 --- a/packages/engine/src/executor/worktree-create-conflict.ts +++ b/packages/engine/src/executor/worktree-create-conflict.ts @@ -90,6 +90,7 @@ export async function tryCreateWorktree( await installTaskWorktreeIdentityGuard({ worktreePath: path, taskId, + expectedBranch: branch, commitMsgHookEnabled: settings.commitMsgHookEnabled, taskPrefix: settings.taskPrefix, taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], diff --git a/packages/engine/src/executor/worktree-stale-branch.ts b/packages/engine/src/executor/worktree-stale-branch.ts index 961e4ad88a..7e8e93549a 100644 --- a/packages/engine/src/executor/worktree-stale-branch.ts +++ b/packages/engine/src/executor/worktree-stale-branch.ts @@ -5,12 +5,14 @@ */ import { exec } from "node:child_process"; import { promisify } from "node:util"; +import { isFusionDeletableBranch, type Task } from "@fusion/core"; const execAsync = promisify(exec); export type StaleBranchCleanupStore = { logEntry: (taskId: string, action: string, outcome?: string) => Promise; clearStaleExecutionStartBranchReferences: (branches: string[], excludingTaskId?: string) => Promise; + getTask?: (taskId: string) => Promise; }; /** @@ -32,6 +34,13 @@ export async function cleanupStaleBranch( branch: string, taskId: string, ): Promise { + // Branch names alone cannot disprove an operator provenance marker. + const task = await store.getTask?.(taskId); + if (!task || !isFusionDeletableBranch(task, branch)) { + await store.logEntry(taskId, `Kept branch with unknown or operator provenance`, branch); + return false; + } + // Step 1: Prune stale worktree metadata that may hold a lock on the branch try { await execAsync("git worktree prune", { cwd: rootDir }); diff --git a/packages/engine/src/healing/restart-recovery-coordinator.ts b/packages/engine/src/healing/restart-recovery-coordinator.ts index 002f4f87d8..dd1118dc6a 100644 --- a/packages/engine/src/healing/restart-recovery-coordinator.ts +++ b/packages/engine/src/healing/restart-recovery-coordinator.ts @@ -229,7 +229,7 @@ export class RestartRecoveryCoordinator { await this.store.updateTask(task.id, { status: "stuck-killed", worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, sessionFile: null, error: null, }); diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 5e5ae6d668..c382a88fdd 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -125,6 +125,7 @@ import { resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveTaskLifecycleColumns, + isFusionDeletableBranch, type WorkflowIr, resolveReviewColumns } from "@fusion/core"; import { evaluateAutoMergeFactProviders } from "./merge/auto-merge-fact-providers.js"; @@ -6627,7 +6628,7 @@ async function tryEarlyEmptyOwnDiffFinalize(input: { } } } - if (ownedBranchOnEntry) { + if (ownedBranchOnEntry && isFusionDeletableBranch(task, ownedBranchOnEntry)) { try { // Branch must be deleted from the project root, not from inside a // worktree that may still be checked out to it. @@ -9705,19 +9706,23 @@ export async function aiMergeTask( } } - // 6. Delete branch - try { - await execAsync(`git branch -d "${branch}"`, { cwd: rootDir }); - result.branchDeleted = true; - // Audit trail: record branch deletion (FN-1404) - await audit.git({ type: "branch:delete", target: branch }); - } catch { + // 6. Delete only a Fusion-proven branch; a skipped delete has no audit event. + if (isFusionDeletableBranch(task, branch)) { try { - await execAsync(`git branch -D "${branch}"`, { cwd: rootDir }); + await execAsync(`git branch -d "${branch}"`, { cwd: rootDir }); result.branchDeleted = true; - // Audit trail: record branch deletion (force) (FN-1404) - await audit.git({ type: "branch:delete", target: branch, metadata: { force: true } }); - } catch { /* non-fatal */ } + // Audit trail: record branch deletion (FN-1404) + await audit.git({ type: "branch:delete", target: branch }); + } catch { + try { + await execAsync(`git branch -D "${branch}"`, { cwd: rootDir }); + result.branchDeleted = true; + // Audit trail: record branch deletion (force) (FN-1404) + await audit.git({ type: "branch:delete", target: branch, metadata: { force: true } }); + } catch { /* non-fatal */ } + } + } else { + mergerLog.log(`${taskId}: kept operator-supplied branch ${branch}`); } if (result.branchDeleted) { @@ -9772,7 +9777,7 @@ export async function aiMergeTask( mergerLog.warn(`${taskId}: failed to detach pooled worktree before release: ${msg}`); } try { - await store.updateTask(taskId, { worktree: null, branch: null }); + await store.updateTask(taskId, { worktree: null, branch: null, branchWriteOrigin: "engine" }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); mergerLog.warn(`${taskId}: failed to clear worktree pointer before pool release: ${msg}`); @@ -9805,7 +9810,7 @@ export async function aiMergeTask( } if (result.worktreeRemoved) { try { - await store.updateTask(taskId, { worktree: null, branch: null }); + await store.updateTask(taskId, { worktree: null, branch: null, branchWriteOrigin: "engine" }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); mergerLog.warn(`${taskId}: failed to clear worktree pointer after removal: ${msg}`); diff --git a/packages/engine/src/recovery/foreign-only-contamination.ts b/packages/engine/src/recovery/foreign-only-contamination.ts index 7093218024..4a803dbbf2 100644 --- a/packages/engine/src/recovery/foreign-only-contamination.ts +++ b/packages/engine/src/recovery/foreign-only-contamination.ts @@ -1,7 +1,7 @@ import { exec } from "node:child_process"; import { existsSync } from "node:fs"; import { promisify } from "node:util"; -import type { Task, TaskStore } from "@fusion/core"; +import { isFusionDeletableBranch, type Task, type TaskStore } from "@fusion/core"; import { activeSessionRegistry } from "../agents/active-session-registry.js"; import { resolveReboundTargetForTask } from "@fusion/core"; import { @@ -109,7 +109,9 @@ export async function recoverForeignOnlyContamination( } await execAsync("git worktree prune", { cwd: deps.repoDir, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }).catch(() => undefined); - await execAsync(`git branch -D ${quote(task.branch)}`, { cwd: deps.repoDir, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }).catch(() => undefined); + if (isFusionDeletableBranch(task, task.branch)) { + await execAsync(`git branch -D ${quote(task.branch)}`, { cwd: deps.repoDir, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }).catch(() => undefined); + } /* FNXC:WorkflowResolvedColumns 2026-07-30-19:55 (#2808 review — coderabbit): census-invisible moveTask DESTINATION — a call argument, not a comparison, so the census never scored it. This requeue is not a @@ -129,7 +131,7 @@ export async function recoverForeignOnlyContamination( paused: false, pausedReason: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null, modifiedFiles: [], }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index b5ede5014d..6b93f95b08 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -41,6 +41,7 @@ import { loadWorkspaceConfig, type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_ resolveAgentActivityAttribution, resolveEngineIncarnationId, resolveEngineNodeId, + isFusionDeletableBranch, } from "@fusion/core"; import { finalizePlanningSegment } from "@fusion/core"; import type { WorkspaceLandIntent } from "@fusion/core"; @@ -1275,7 +1276,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { : { ok: false, classification: cls.classification, reason: cls.reason }; worktreeUnusable = !cls.ok; } else { - const expected = canonicalFusionBranchName(task.id); + const expected = resolveTaskWorkingBranch(task); const registeredPaths = await getRegisteredWorktreePaths(this.options.rootDir); const registeredBranchMap = await getRegisteredWorktreeBranchMap(this.options.rootDir); const matchingRegisteredPaths = [...registeredPaths].filter((path) => { @@ -2702,6 +2703,10 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } const branch = resolveTaskWorkingBranch(task); + if (!isFusionDeletableBranch(task, branch)) { + log.log(`Kept operator-supplied branch ${branch} for ${task.id}`); + return; + } try { await execAsync(`git branch -D ${shellQuote(branch)}`, { cwd: this.options.rootDir, @@ -4074,7 +4079,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { return withPerPr({ outcome: "skipped", reason: "stale" }); } if (inspection.kind === "stale-resolved") { - await this.store.updateTask(task.id, { worktree: null, branch: null, baseCommitSha: null }); + await this.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null }); await auditor.database({ type: "task:pr-conflict-reclaim", target: task.id, metadata: { outcome: "stale-resolved" } }); return withPerPr({ outcome: "stale-resolved" }); } @@ -4117,8 +4122,10 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (canAutoReclaimLiveZero) { await removeWorktree({ rootDir: this.options.rootDir, worktreePath: inspection.livePath, settings, taskId: task.id, reason: RemovalReason.SelfHealingBranchConflict }); await execAsync("git worktree prune", { cwd: this.options.rootDir, timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }); - await execAsync(`git branch -D ${JSON.stringify(task.branch)}`, { cwd: this.options.rootDir, timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }); - await this.store.updateTask(task.id, { worktree: null, branch: null, paused: false, pausedReason: undefined, status: null, error: null }); + if (isFusionDeletableBranch(task, task.branch)) { + await execAsync(`git branch -D ${JSON.stringify(task.branch)}`, { cwd: this.options.rootDir, timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }); + } + await this.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, paused: false, pausedReason: undefined, status: null, error: null }); await auditor.database({ type: "task:pr-conflict-reclaim", target: task.id, metadata: { outcome: "reclaimed", mode: "fully-subsumed", recoveredFromPaused: wasPausedBranchConflict } }); return withPerPr({ outcome: "reclaimed" }); } @@ -4500,7 +4507,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (inspection.kind === "stale-resolved") { await this.store.updateTask(task.id, { worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null, }); await this.store.logEntry( @@ -4589,15 +4596,17 @@ export class SelfHealingManager extends SelfHealingGitEvidence { timeout: 120_000, maxBuffer: 10 * 1024 * 1024, }); - await execAsync(`git branch -D ${JSON.stringify(branchName)}`, { - cwd: this.options.rootDir, + if (isFusionDeletableBranch(task, branchName)) { + await execAsync(`git branch -D ${JSON.stringify(branchName)}`, { + cwd: this.options.rootDir, timeout: 120_000, - maxBuffer: 10 * 1024 * 1024, - }); + maxBuffer: 10 * 1024 * 1024, + }); + } await this.store.updateTask(task.id, { worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null, paused: false, pausedReason: undefined, @@ -4719,15 +4728,17 @@ export class SelfHealingManager extends SelfHealingGitEvidence { timeout: 120_000, maxBuffer: 10 * 1024 * 1024, }); - await execAsync(`git branch -D ${JSON.stringify(task.branch)}`, { - cwd: this.options.rootDir, + if (isFusionDeletableBranch(task, task.branch)) { + await execAsync(`git branch -D ${JSON.stringify(task.branch)}`, { + cwd: this.options.rootDir, timeout: 120_000, - maxBuffer: 10 * 1024 * 1024, - }); + maxBuffer: 10 * 1024 * 1024, + }); + } await this.store.updateTask(task.id, { worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, paused: false, pausedReason: undefined, status: null, @@ -5156,6 +5167,10 @@ export class SelfHealingManager extends SelfHealingGitEvidence { ? "complete-column-unique-commits-force" : "zero-unique-commits-no-worktree"; + if (!isFusionDeletableBranch(task, branch)) { + log.log(`[self-healing] kept operator-supplied branch ${branch} for ${task.id}`); + continue; + } await execAsync(`git branch -D ${JSON.stringify(branch)}`, { cwd: this.options.rootDir, timeout: 120_000, @@ -5172,7 +5187,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { await this.store.updateTask(task.id, { worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null, }); await this.store.logEntry( @@ -5249,6 +5264,11 @@ export class SelfHealingManager extends SelfHealingGitEvidence { return false; } + if (!isFusionDeletableBranch(task, branchName)) { + log.log(`[self-healing] reconcileCompletedTask ${task.id}: kept operator-supplied branch ${branchName}`); + return false; + } + const baseBranch = task.baseBranch || await resolveIntegrationBranch(this.options.rootDir, undefined); const comparison = await listUniqueBranchCommits(this.options.rootDir, baseBranch, branchName); if (comparison.commits.length > 0) { @@ -5485,7 +5505,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (task) { const patch = { worktree: null as string | null, - ...(task.branch === branchName ? { branch: null as string | null } : {}), + ...(task.branch === branchName ? { branch: null as string | null, branchWriteOrigin: "engine" as const } : {}), }; await this.store.updateTask(task.id, patch as Partial); } @@ -5893,7 +5913,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (executingIds.has(task.id)) continue; if (activeSessionRegistry.isPathActive(task.worktree)) continue; - const normalizedBranch = canonicalFusionBranchName(task.id); + const normalizedBranch = resolveTaskWorkingBranch(task); const resolvedTaskWorktree = resolve(task.worktree); const realpathTaskWorktree = safeRealpath(resolvedTaskWorktree); const stale = !existsSync(task.worktree) || !registeredRealpaths.has(realpathTaskWorktree); @@ -5904,7 +5924,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const liveWorktree = branchMap.get(normalizedBranch); if (liveWorktree) { - await this.store.updateTask(task.id, { worktree: liveWorktree, branch: normalizedBranch }); + await this.store.updateTask(task.id, { worktree: liveWorktree }); await this.emitWorktreeMetadataAuditEvent({ taskId: task.id, mutationType: "task:auto-recover-worktree-metadata-rebound", @@ -5932,7 +5952,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { FNXC:MissingWorktreeRecovery 2026-07-10 (code review): the FN-5256 guard immediately below exists precisely because column==="in-progress"/"in-review" tasks can be live even when this heuristic's existsSync/registered-path check calls them stale (that's the guard's own stated rationale). Restrict this scopeOverride bypass to the narrow #1992 bug shape reproduced in the task report — in-review AND a merge-active sub-status (merging/merging-pr/merging-fix) — so a scopeOverride task that is genuinely in-progress, or in-review mid-step (status: null) with a live but momentarily undetected session, still falls through to the FN-5256 protection instead of having its worktree/branch/sessionFile yanked out from under it. */ - await this.store.updateTask(task.id, { worktree: null, branch: null, sessionFile: null }); + await this.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, sessionFile: null }); await this.emitWorktreeMetadataAuditEvent({ taskId: task.id, mutationType: "task:auto-recover-worktree-metadata-cleared", @@ -5967,7 +5987,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { continue; } - await this.store.updateTask(task.id, { worktree: null, branch: null }); + await this.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" }); await this.emitWorktreeMetadataAuditEvent({ taskId: task.id, mutationType: "task:auto-recover-worktree-metadata-cleared", @@ -10520,7 +10540,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane); if (unrecoverableRepos.length > 0) { // FORK-A: at least one repo is proven branch-gone and not landed → park failed. - const error = `Workspace partial-land unrecoverable: sub-repo(s) ${unrecoverableRepos.join(", ")} have no fusion/${task.id.toLowerCase()} branch and no landedSha — manual intervention required.`; + const missingBranches = unrecoverableRepos + .map((repoRel) => workspaceWorktrees[repoRel]?.branch ?? resolveTaskWorkingBranch(task)) + .join(", "); + const error = `Workspace partial-land unrecoverable: sub-repo(s) ${unrecoverableRepos.join(", ")} have no branch (${missingBranches}) and no landedSha — manual intervention required.`; /* FNXC:Workspace 2026-08-15-07:17: This is the third and final writer of the display-only failure breadcrumb. Persist each @@ -10985,7 +11008,12 @@ const movedTask = await this.store.moveTask(task.id, completeLane); pruned = true; this.prunedWorkspaceWorktreeTeardowns.add(entryKey); } - if (branch && branch === canonicalFusionBranchName(task.id) && worktreeGone) { + /* + FNXC:WorkspaceBranchDeletion 2026-08-20-03:39: + Workspace teardown remains conservative: canonical spelling is not provenance proof, + so delete only when the core classifier accepts this task branch. + */ + if (branch && isFusionDeletableBranch(task, branch) && worktreeGone) { if (branchClaimCount > 1) branchOutcome = "retained-duplicate-claim"; else { /* @@ -11675,7 +11703,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane); error: null, mergeRetries: 0, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, mergeDetails, }); await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-stuck-merge-deadlocks"); @@ -12817,7 +12845,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane); await this.store.updateTask(task.id, { mergeDetails, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, worktree: null, status: null, error: null, @@ -13941,7 +13969,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane); status: null, error: null, worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, checkedOutBy: null, executionStartedAt: null, worktreeSessionRetryCount: null, @@ -15117,7 +15145,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane); await this.store.updateTask(task.id, { status: "stuck-killed", worktree: null, - branch: null, + branch: null, branchWriteOrigin: "engine" as const, }); await this.store.logEntry( task.id, diff --git a/packages/engine/src/worktree/worktree-acquisition.ts b/packages/engine/src/worktree/worktree-acquisition.ts index a3e63b1e46..cdb424aebb 100644 --- a/packages/engine/src/worktree/worktree-acquisition.ts +++ b/packages/engine/src/worktree/worktree-acquisition.ts @@ -5,7 +5,7 @@ import { exec } from "node:child_process"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { promisify } from "node:util"; import { acquireWorktreePathReservation, assertWorkspaceRepoRelPath, canonicalizeWorktreePath, resolveEngineIncarnationId, resolveEngineNodeId, workspaceWorktreeGroupSegment, WORKSPACE_GROUP_MARKER_FILENAME, type RunMutationContext, type Settings, type Task, type TaskStore, type SecretsStore, type WorkspaceConfig, type WorkspaceLeaseHandle, type WorkspaceWorktreeContext } from "@fusion/core"; -import { generateWorktreeName, resolveTaskWorkingBranch, slugify } from "./worktree-names.js"; +import { generateWorktreeName, resolveTaskWorkingBranchWithOrigin, slugify } from "./worktree-names.js"; import { resolveTaskWorktreePathForBackend, resolveWorktreesDir, WORKTREE_RECOVERY_DIRNAME } from "./worktree-paths.js"; import { hydrateWorktreeDb } from "./worktree-db-hydrate.js"; import { formatError } from "../logger.js"; @@ -485,7 +485,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro throw error; } await ensureWorkspaceGroupOwnership(workspaceContext, settings); - const branchName = resolveTaskWorkingBranch(task); + const workingBranch = resolveTaskWorkingBranchWithOrigin(task); + const branchName = workingBranch.branch; const resolveExistingWorktreeBackendKind = async (path: string): Promise => (await readPersistedWorktreeBackendKind(path)) ?? opts.createWorktreeBackendKind ?? backend.kind; const naming = settings.worktreeNaming || "random"; @@ -539,7 +540,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro }); logger?.log(`${task.id}: assigned worktree is not usable; creating a fresh worktree instead: ${worktreePath}`); await store.logEntry(task.id, "Assigned worktree is not a registered, usable git worktree; creating a fresh worktree instead", worktreePath, runContext); - await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null }); + await persistWorktreeAssignment({ worktree: null, branch: null, branchWriteOrigin: "engine" as const, sessionFile: null }); const fallbackName = generateWorktreeName(rootDir, settings, workspaceContext); worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName, workspaceContext); isResume = false; @@ -580,6 +581,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro createTaskId: string, startPoint?: string, allowRename?: boolean, + branchOrigin?: "engine-canonical" | "group-derived" | "operator-supplied", ): Promise<{ path: string; branch: string; backendKind: WorktreeBackend["kind"] }> => { try { const created = await backend.create({ @@ -588,6 +590,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro worktreePath: createPath, startPoint, taskId: createTaskId, + branchOrigin, allowSiblingBranchRename: allowRename, }); if (backend.kind === "worktrunk") { @@ -609,6 +612,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro worktreePath: createPath, startPoint, taskId: createTaskId, + branchOrigin, allowSiblingBranchRename: allowRename, }); const created = await handleWorktrunkFailure("create", error, fallback) as { path: string; branch: string }; @@ -624,12 +628,13 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro startPoint?: string, allowRename?: boolean, reservationHeld = false, + branchOrigin?: "engine-canonical" | "group-derived" | "operator-supplied", ): Promise<{ path: string; branch: string; backendKind: WorktreeBackend["kind"] }> => { if (createWorktree) { const created = await createWorktree(createBranch, createPath, createTaskId, startPoint, allowRename); return { ...created, backendKind: opts.createWorktreeBackendKind ?? backend.kind }; } - if (reservationHeld) return createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename); + if (reservationHeld) return createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename, branchOrigin); const reservation = await acquireWorktreePathReservation({ canonicalPath: await canonicalizeWorktreePath(createPath), worktreesDir: resolveWorktreesDir(rootDir, settings, workspaceContext), @@ -652,7 +657,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro }, }); try { - return await createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename); + return await createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename, branchOrigin); } finally { if (reservation.state === "held") await reservation.release(); } @@ -709,7 +714,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro */ if (isRepoRootPath(rootDir, created.path)) { await emitRepoRootReturnGuardAudit(created.path, source); - await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null }); + await persistWorktreeAssignment({ worktree: null, branch: null, branchWriteOrigin: "engine" as const, sessionFile: null }); throw new RepoRootWorktreeError(task.id, rootDir, created.path, `fresh-create:${logOrigin}`); } @@ -791,10 +796,10 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro await emitRepoRootReturnGuardAudit(guardedPath, source); logger?.warn(`${task.id}: acquisition ${source} returned repo root; clearing assignment and creating a fresh worktree`); await store.logEntry(task.id, "Acquisition attempted to return the project root as a task worktree; creating a fresh worktree instead", guardedPath, runContext); - await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null }); + await persistWorktreeAssignment({ worktree: null, branch: null, branchWriteOrigin: "engine" as const, sessionFile: null }); const fallbackName = generateWorktreeName(rootDir, settings, workspaceContext); const fallbackPath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName, workspaceContext); - const created = await createWorktreeImpl(branchName, fallbackPath, task.id, freshStartPoint, allowSiblingBranchRename); + const created = await createWorktreeImpl(branchName, fallbackPath, task.id, freshStartPoint, allowSiblingBranchRename, false, workingBranch.origin); return finalizeCreatedWorktree(created, "fresh", "return-guard"); }; @@ -1015,7 +1020,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro await store.updateTask(task.id, { sessionFile: null }); } - const created = await createWorktreeImpl(branchName, pinnedPath, task.id, freshStartPoint, allowSiblingBranchRename, true); + const created = await createWorktreeImpl(branchName, pinnedPath, task.id, freshStartPoint, allowSiblingBranchRename, true, workingBranch.origin); return await finalizeCreatedWorktree(created, "fresh", "normal"); } finally { if (reservation.state === "held") await reservation.release(); @@ -1069,6 +1074,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro allowSiblingBranchRename, repoDir: rootDir, requestingTaskId: task.id, + branchOrigin: workingBranch.origin, }); const prepared = typeof preparedRaw === "string" ? { branch: preparedRaw, worktreePath: pooled, reclaimed: false as const } @@ -1118,6 +1124,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro await installTaskWorktreeIdentityGuard({ worktreePath, taskId: task.id, + expectedBranch: branch, commitMsgHookEnabled: settings.commitMsgHookEnabled, taskPrefix: settings.taskPrefix, taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], @@ -1186,7 +1193,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro if (poolErr instanceof WorktreeBaseRefreshError) { // FNXC:WorktreeBaseRefresh 2026-08-09-03:30: Clear every durable resume binding before returning the // checkout to the pool. If persistence fails, retain the lease so no other task can mutate it. - await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null }); + await persistWorktreeAssignment({ worktree: null, branch: null, branchWriteOrigin: "engine" as const, sessionFile: null }); pool.release(pooled, task.id); throw poolErr; } @@ -1206,7 +1213,15 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro // Worktree removal in merger.ts, worktree-pool.ts, and self-healing.ts is now // backend-mediated via WorktreeBackend.remove(). executor.ts and // step-session-executor.ts remain native-only paths (tracked separately). - const created = await createWorktreeImpl(branchName, worktreePath, task.id, freshStartPoint, allowSiblingBranchRename); + const created = await createWorktreeImpl( + branchName, + worktreePath, + task.id, + freshStartPoint, + allowSiblingBranchRename, + false, + workingBranch.origin, + ); return finalizeCreatedWorktree(created, acquiredFromPool ? "pool" : "fresh", "normal"); } @@ -1519,8 +1534,20 @@ export async function acquireWorkspaceRepoWorktree( settings, logger, }); + /* + FNXC:WorkspaceBranches 2026-08-20-03:38: + FN-9161 uses one explicit operator branch in every workspace repository. + Keep only that branch through the singular-worktree isolation copy; derived + and canonical assignments retain the existing per-repository behavior. + */ + const workspaceWorkingBranch = resolveTaskWorkingBranchWithOrigin(task); const result = await acquireTaskWorktree({ - task: { ...task, worktree: undefined, branch: undefined, executionStartBranch: baseResolution.branch }, + task: { + ...task, + worktree: undefined, + branch: workspaceWorkingBranch.origin === "operator-supplied" ? workspaceWorkingBranch.branch : undefined, + executionStartBranch: baseResolution.branch, + }, suppressSingularWorktreePersist: true, workspaceContext: { workspaceRootDir, repoRelPath }, rootDir: repoAbsPath, @@ -1566,6 +1593,7 @@ export async function acquireWorkspaceRepoWorktree( await installTaskWorktreeIdentityGuard({ worktreePath: result.worktreePath, taskId: task.id, + expectedBranch: result.branch, commitMsgHookEnabled: settings.commitMsgHookEnabled, taskPrefix: settings.taskPrefix, taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], diff --git a/packages/engine/src/worktree/worktree-backend.ts b/packages/engine/src/worktree/worktree-backend.ts index 9fb5ee08ef..8ec413618a 100644 --- a/packages/engine/src/worktree/worktree-backend.ts +++ b/packages/engine/src/worktree/worktree-backend.ts @@ -201,6 +201,8 @@ export interface WorktreeCreateInput { worktreePath: string; startPoint?: string; taskId: string; + /** FNXC:WorkspaceBranches 2026-08-20-03:38: provenance controls safe existing-branch attachment. */ + branchOrigin?: "engine-canonical" | "group-derived" | "operator-supplied"; allowSiblingBranchRename?: boolean; } @@ -347,11 +349,13 @@ export class NativeWorktreeBackend implements WorktreeBackend { async create(input: WorktreeCreateInput): Promise { const startArg = input.startPoint ? ` ${quoteShellArg(input.startPoint)}` : ""; - const installGuardOrCleanup = async (worktreePath: string) => { + const installGuardOrCleanup = async (worktreePath: string, expectedBranch: string) => { try { await installTaskWorktreeIdentityGuard({ worktreePath, taskId: input.taskId, + // The hook must follow the branch Git actually checked out: sibling collision recovery can rename it. + expectedBranch, commitMsgHookEnabled: this.deps.settings?.commitMsgHookEnabled, taskPrefix: this.deps.settings?.taskPrefix, taskAttributionTrailerName: this.deps.settings?.taskAttributionTrailerNames?.[0], @@ -417,7 +421,7 @@ export class NativeWorktreeBackend implements WorktreeBackend { let staleRegistrationRecoveryAttempted = false; try { const created = await createWithBranch(input.branch); - await installGuardOrCleanup(created.path); + await installGuardOrCleanup(created.path, created.branch); return created; } catch (error) { const lockPath = parseIndexLockPath(`${(error as { message?: string })?.message ?? ""}\n${getErrorStderr(error) ?? ""}`); @@ -449,7 +453,7 @@ export class NativeWorktreeBackend implements WorktreeBackend { metadata: { lockPath }, }); const created = await createWithBranch(input.branch); - await installGuardOrCleanup(created.path); + await installGuardOrCleanup(created.path, created.branch); return created; } await this.deps.audit?.git({ @@ -507,7 +511,7 @@ export class NativeWorktreeBackend implements WorktreeBackend { target: input.worktreePath, metadata: { actions: recovery.actions }, }); - await installGuardOrCleanup(created.path); + await installGuardOrCleanup(created.path, created.branch); return created; } catch (retryError) { const actionsWithForce = [...recovery.actions, "add-force-retry"]; @@ -518,7 +522,7 @@ export class NativeWorktreeBackend implements WorktreeBackend { target: input.worktreePath, metadata: { actions: actionsWithForce }, }); - await installGuardOrCleanup(created.path); + await installGuardOrCleanup(created.path, created.branch); return created; } catch (forceError) { await this.deps.audit?.git({ @@ -542,6 +546,27 @@ export class NativeWorktreeBackend implements WorktreeBackend { const isBareBranchCollision = /(?:a\s+)?branch named ["']?.+["']? already exists|branch ["']?.+["']? already exists/i.test(combinedErrorOutput); if (isBareBranchCollision) { + /* + * FNXC:WorkspaceBranches 2026-08-20-03:38: + * FN-9161 preserves explicit operator branch ownership, including + * Fusion-shaped names. Attach its existing bare branch directly; + * Git still rejects a branch checked out by another live worktree. + */ + if (input.branchOrigin === "operator-supplied") { + try { + const created = await attachExistingBranch(); + await installGuardOrCleanup(created.path, created.branch); + await this.deps.audit?.git({ + type: "worktree:branch-collision-recovery", + target: input.worktreePath, + metadata: { taskId: input.taskId, disposition: "attach-operator-branch" }, + }); + return created; + } catch (attachError) { + await cleanupPartialCollisionRecovery(); + throw attachError; + } + } /* * FNXC:WorktreeAcquisition 2026-07-16-00:00: * FN-8132 / #2232 recovers only a bare branch-name collision after the @@ -578,7 +603,7 @@ export class NativeWorktreeBackend implements WorktreeBackend { if (inspection.kind === "reclaimable") { try { const created = await attachExistingBranch(); - await installGuardOrCleanup(created.path); + await installGuardOrCleanup(created.path, created.branch); await this.deps.audit?.git({ type: "worktree:branch-collision-recovery", target: input.worktreePath, @@ -599,7 +624,7 @@ export class NativeWorktreeBackend implements WorktreeBackend { maxBuffer: MAX_BUFFER, }); const created = await createWithBranch(input.branch); - await installGuardOrCleanup(created.path); + await installGuardOrCleanup(created.path, created.branch); await this.deps.audit?.git({ type: "worktree:branch-collision-recovery", target: input.worktreePath, @@ -621,7 +646,7 @@ export class NativeWorktreeBackend implements WorktreeBackend { const candidateBranch = `${input.branch}-${suffix}`; try { const created = await createWithBranch(candidateBranch); - await installGuardOrCleanup(created.path); + await installGuardOrCleanup(created.path, created.branch); return created; } catch { // continue probing suffixes @@ -851,6 +876,7 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend { await installTaskWorktreeIdentityGuard({ worktreePath: resolvedPath, taskId: input.taskId, + expectedBranch: input.branch, commitMsgHookEnabled: this.deps.settings?.commitMsgHookEnabled, taskPrefix: this.deps.settings?.taskPrefix, taskAttributionTrailerName: this.deps.settings?.taskAttributionTrailerNames?.[0], diff --git a/packages/engine/src/worktree/worktree-hooks.ts b/packages/engine/src/worktree/worktree-hooks.ts index dfe60da661..61a2f51f68 100644 --- a/packages/engine/src/worktree/worktree-hooks.ts +++ b/packages/engine/src/worktree/worktree-hooks.ts @@ -32,11 +32,15 @@ function toShellCasePattern(pattern: string): string { * Build the shared pre-commit identity-guard hook. * * The emitted script must stay metadata-aware because linked git worktrees share - * the common hooks directory. It bakes in the install-time taskId as the default - * expected branch, then falls back to `fusion-task-id` when runtime metadata - * drifts so the shared hook still follows the current owning task. + * the common hooks directory. It bakes in the install-time working branch, + * then falls back to `fusion-task-id` when runtime metadata drifts so the shared + * hook still follows the current owning task. */ -export function buildIdentityGuardHook(taskId: string, allowedBranchPatterns: readonly string[] = DEFAULT_ALLOWED_BRANCH_PATTERNS): string { +export function buildIdentityGuardHook( + taskId: string, + allowedBranchPatterns: readonly string[] = DEFAULT_ALLOWED_BRANCH_PATTERNS, + expectedBranch = `fusion/${taskId.toLowerCase()}`, +): string { const allowChecks = allowedBranchPatterns.map((pattern) => ` ${toShellCasePattern(pattern)}) exit 0 ;;`).join("\n"); return `#!/bin/sh @@ -71,7 +75,10 @@ fi WORKTREE_TASK_ID=$(cat "$TASK_FILE") # Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId) -EXPECTED_BRANCH=${JSON.stringify(`fusion/${taskId.toLowerCase()}`)} +# FNXC:WorktreeIdentity 2026-08-20-03:38: FN-9161 permits an operator-selected +# branch for a task worktree, so the hook follows that branch rather than +# reconstructing Fusion's default. +EXPECTED_BRANCH=${JSON.stringify(expectedBranch)} if [ "$(printf '%s' "$WORKTREE_TASK_ID" | tr '[:upper:]' '[:lower:]')" != ${JSON.stringify(taskId.toLowerCase())} ]; then EXPECTED_BRANCH="fusion/$(printf '%s' "$WORKTREE_TASK_ID" | tr '[:upper:]' '[:lower:]')" @@ -330,6 +337,7 @@ export async function installTaskWorktreeIdentityGuard(input: { worktreePath: string; taskId: string; allowedBranchPatterns?: readonly string[]; + expectedBranch?: string; commitMsgHookEnabled?: boolean; taskPrefix?: string; taskAttributionTrailerName?: string; @@ -337,7 +345,11 @@ export async function installTaskWorktreeIdentityGuard(input: { commitAuthorName?: string; commitAuthorEmail?: string; }): Promise { - const hook = buildIdentityGuardHook(input.taskId, input.allowedBranchPatterns ?? DEFAULT_ALLOWED_BRANCH_PATTERNS); + const hook = buildIdentityGuardHook( + input.taskId, + input.allowedBranchPatterns ?? DEFAULT_ALLOWED_BRANCH_PATTERNS, + input.expectedBranch, + ); const metadataPath = await resolveGitPath(input.worktreePath, "fusion-task-id"); const hookPath = await resolveGitPath(input.worktreePath, "hooks/pre-commit"); diff --git a/packages/engine/src/worktree/worktree-names.ts b/packages/engine/src/worktree/worktree-names.ts index 27c4e9a747..31a3e1c819 100644 --- a/packages/engine/src/worktree/worktree-names.ts +++ b/packages/engine/src/worktree/worktree-names.ts @@ -1,5 +1,6 @@ import { readdirSync } from "node:fs"; import { existsSync } from "node:fs"; +import { classifyTaskBranchOrigin } from "@fusion/core"; import type { Settings, Task, WorkspaceWorktreeContext } from "@fusion/core"; import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js"; @@ -51,6 +52,18 @@ export function resolveTaskWorkingBranch(task: Pick, +): { branch: string; origin: ReturnType } { + const branch = resolveTaskWorkingBranch(task); + return { branch, origin: classifyTaskBranchOrigin(task, branch) }; +} + /** * Convert a string to a URL-friendly slug. * diff --git a/packages/engine/src/worktree/worktree-pool.ts b/packages/engine/src/worktree/worktree-pool.ts index d7f644bcaa..2cb48331f8 100644 --- a/packages/engine/src/worktree/worktree-pool.ts +++ b/packages/engine/src/worktree/worktree-pool.ts @@ -708,7 +708,7 @@ export class WorktreePool { worktreePath: string, branchName: string, startPoint?: string, - options?: { allowSiblingBranchRename?: boolean; repoDir?: string; requestingTaskId?: string }, + options?: { allowSiblingBranchRename?: boolean; repoDir?: string; requestingTaskId?: string; branchOrigin?: "engine-canonical" | "group-derived" | "operator-supplied" }, ): Promise { // Clean tracked modifications try { @@ -761,6 +761,21 @@ export class WorktreePool { } } const taskId = deriveTaskIdFromBranch(branchName); + /* + FNXC:WorkspaceBranches 2026-08-20-04:18: + Recycling must not turn an operator's requested branch into a new branch at the base. When the + branch already exists, attach it intact; Git continues to refuse a live checkout in another worktree. + */ + if (options?.branchOrigin === "operator-supplied") { + const branchExists = await execAsync(`git show-ref --verify --quiet "refs/heads/${branchName}"`, { cwd: worktreePath }) + .then(() => true) + .catch(() => false); + if (branchExists) { + // A checkout failure here is intentional: Git preserves its live-worktree refusal. + await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath }); + return { branch: branchName, worktreePath, reclaimed: false }; + } + } try { await execAsync(checkoutCmd, { cwd: worktreePath,