feat(FN-3932): add orphaned finalize-reset autostash safeguard in merger

The merger gains a safeguard that automatically stashes work when a finalize-reset leaves orphaned records, preventing merge-state corruption. Core exports the new orphan record type, project-engine wires the safeguard, and documentation traces the provenance flow; tests were added for the recovery

Fusion-Task-Id: FN-3932
This commit is contained in:
Fusion
2026-05-10 14:19:53 -07:00
committed by gsxdsm
parent 06daea7e3d
commit bcb4107a1a
11 changed files with 241 additions and 28 deletions

View File

@@ -1269,6 +1269,7 @@ When a tracked task transitions into `done`, Fusion closes the linked GitHub iss
#### Autostash lifecycle #### Autostash lifecycle
- Before destructive merge prep, `stashUnrelatedRootDirChanges()` snapshots dirty root-dir edits into `fusion-merger-autostash:<taskId>:<ts>` (plus optional `race-rescue-*` stashes for late writes). - Before destructive merge prep, `stashUnrelatedRootDirChanges()` snapshots dirty root-dir edits into `fusion-merger-autostash:<taskId>:<ts>` (plus optional `race-rescue-*` stashes for late writes).
- During verification-fix finalize fallback, `commitOrAmendMergeWithFixes()` now snapshots any still-dirty root-dir state into `fusion-merger-autostash:<taskId>:finalize-reset:<ts>` *before* its hard reset/clean recovery path, preventing silent mixed-worktree leftovers from being discarded.
- In `aiMergeTask` cleanup, `restoreUnrelatedRootDirChanges()` attempts restore; then `dropAutostashHandle()` runs on every terminal path and drops primary + race-rescue stashes when restoration succeeded or content is no longer live. - In `aiMergeTask` cleanup, `restoreUnrelatedRootDirChanges()` attempts restore; then `dropAutostashHandle()` runs on every terminal path and drops primary + race-rescue stashes when restoration succeeded or content is no longer live.
- If restore fails with unresolved developer work (`failed`/`conflict-needs-manual`), cleanup uses a keep-if-live rule so still-live stashes are preserved for manual recovery. - If restore fails with unresolved developer work (`failed`/`conflict-needs-manual`), cleanup uses a keep-if-live rule so still-live stashes are preserved for manual recovery.
- `sweepAutostashOrphans()` keeps its subsumed/live classification for prior-run leftovers, and `sweepStaleAutostashes()` adds an age-based backstop that drops `fusion-merger-autostash:*` entries older than the configured threshold (default 24h). - `sweepAutostashOrphans()` keeps its subsumed/live classification for prior-run leftovers, and `sweepStaleAutostashes()` adds an age-based backstop that drops `fusion-merger-autostash:*` entries older than the configured threshold (default 24h).
@@ -1277,6 +1278,8 @@ When a tracked task transitions into `done`, Fusion closes the linked GitHub iss
- Orphans are typically residual `fusion-merger-autostash:*` entries from older merge runs where restore could not safely complete. - Orphans are typically residual `fusion-merger-autostash:*` entries from older merge runs where restore could not safely complete.
- Existing task-scoped surfacing remains: merger warnings still log to `mergerLog.warn` and `store.logEntry` for the active merge task. - Existing task-scoped surfacing remains: merger warnings still log to `mergerLog.warn` and `store.logEntry` for the active merge task.
- New global surfacing adds `merger:autostashOrphans` TaskStore events, engine helpers (`listAutostashOrphans`, `getAutostashDiff`, `applyAutostashBySha`, `dropAutostashBySha`), and dashboard API endpoints under `/api/stash-recovery/*`. - New global surfacing adds `merger:autostashOrphans` TaskStore events, engine helpers (`listAutostashOrphans`, `getAutostashDiff`, `applyAutostashBySha`, `dropAutostashBySha`), and dashboard API endpoints under `/api/stash-recovery/*`.
- `merger:autostashOrphans` records now include provenance fields (`sourcePhase`, `detectedByTaskId`, `detectedAt`) so operators can attribute leftovers to the merge phase and surfacing task/session.
- `ProjectEngine` consumes the orphan event stream and auto-creates deduplicated `sourceType: "recovery"` follow-up tasks keyed by `sourceParentTaskId` for live leftovers, so repeated detections do not spam the board.
- Dashboard operators can inspect orphan counts, review diffs, apply stashes, and explicitly drop entries with confirmation. - Dashboard operators can inspect orphan counts, review diffs, apply stashes, and explicitly drop entries with confirmation.
- Decision: recovery stays user-gated. Auto-apply was rejected because clean-tree checks are racy, stash placement is ambiguous after source task merge, and apply conflicts can produce hard-to-untangle state. `sweepAutostashOrphans` continues to auto-drop only subsumed entries while preserving live developer work. - Decision: recovery stays user-gated. Auto-apply was rejected because clean-tree checks are racy, stash placement is ambiguous after source task merge, and apply conflicts can produce hard-to-untangle state. `sweepAutostashOrphans` continues to auto-drop only subsumed entries while preserving live developer work.

View File

@@ -363,6 +363,7 @@ Navigation:
Features: Features:
- Lists orphaned stash entries grouped by source task ID (or **Unknown source** when unavailable) - Lists orphaned stash entries grouped by source task ID (or **Unknown source** when unavailable)
- Surfaces provenance metadata from recovery events (`sourcePhase`, `detectedByTaskId`, `detectedAt`) to show where/when leftovers were captured and surfaced
- Inspect diff output for any orphaned stash before taking action - Inspect diff output for any orphaned stash before taking action
- Apply a stash to recover changes, or drop a stash with confirmation to permanently remove it - Apply a stash to recover changes, or drop a stash with confirmation to permanently remove it

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js"; export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js"; export { AGENT_VALID_TRANSITIONS } from "./types.js";
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js"; export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
export * from "./mesh-replication-protocol.js"; export * from "./mesh-replication-protocol.js";

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs"; import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent } from "./types.js"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { normalizeTaskPriority } from "./task-priority.js"; import { normalizeTaskPriority } from "./task-priority.js";
@@ -466,15 +466,7 @@ export interface TaskStoreEvents {
"agent:log": [entry: AgentLogEntry]; "agent:log": [entry: AgentLogEntry];
"merger:autostashOrphans": [data: { "merger:autostashOrphans": [data: {
rootDir: string; rootDir: string;
records: Array<{ records: AutostashOrphanRecord[];
sha: string;
ref: string;
label: string;
sourceTaskId: string | null;
createdAt: string | null;
changedPaths: string[];
classification: "subsumed" | "live" | "unknown";
}>;
}]; }];
} }

View File

@@ -2503,6 +2503,22 @@ export interface DistributedTaskIdStateResult {
lastCommittedTaskId?: string; lastCommittedTaskId?: string;
} }
export interface AutostashOrphanRecord {
sha: string;
ref: string;
label: string;
sourceTaskId: string | null;
createdAt: string | null;
changedPaths: string[];
classification: "subsumed" | "live" | "unknown";
/** Merge/recovery phase that created this stash label when known. */
sourcePhase?: string | null;
/** Task that detected/surfaced this orphan in the current run. */
detectedByTaskId?: string | null;
/** ISO timestamp when this orphan was surfaced in the current run. */
detectedAt?: string | null;
}
/** /**
* Outcome of restoring the developer's pre-merge autostash after the merge * Outcome of restoring the developer's pre-merge autostash after the merge
* completes. Surfaced on MergeResult so the UI / dashboard can show whether * completes. Surfaced on MergeResult so the UI / dashboard can show whether

View File

@@ -51,6 +51,9 @@ describe("stash recovery routes", () => {
createdAt: null, createdAt: null,
changedPaths: ["file.txt"], changedPaths: ["file.txt"],
classification: "live", classification: "live",
sourcePhase: "finalize-reset",
detectedByTaskId: "FN-1234",
detectedAt: "2026-05-10T00:00:00.000Z",
}, },
]); ]);
@@ -58,6 +61,7 @@ describe("stash recovery routes", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.count).toBe(1); expect(res.body.count).toBe(1);
expect(res.body.records[0].sha).toBe("abcdef1"); expect(res.body.records[0].sha).toBe("abcdef1");
expect(res.body.records[0].sourcePhase).toBe("finalize-reset");
}); });
it("returns diff + truncated flag", async () => { it("returns diff + truncated flag", async () => {

View File

@@ -55,6 +55,8 @@ type MockTaskStore = {
logEntry: ReturnType<typeof vi.fn>; logEntry: ReturnType<typeof vi.fn>;
getActiveMergingTask: ReturnType<typeof vi.fn>; getActiveMergingTask: ReturnType<typeof vi.fn>;
createTask: ReturnType<typeof vi.fn>; createTask: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
}; };
const TASK_ID = "FN-2084"; const TASK_ID = "FN-2084";
@@ -110,6 +112,8 @@ function makeStore({
id: "FN-9999", id: "FN-9999",
description: input.description, description: input.description,
})), })),
on: vi.fn(),
off: vi.fn(),
}; };
} }
@@ -197,6 +201,57 @@ describe("ProjectEngine merge error recovery", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("creates one recovery follow-up for live autostash orphans and dedupes by parent task", async () => {
const store = makeStore();
store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce([
{ id: "FN-9000", column: "todo", sourceType: "recovery", sourceParentTaskId: "FN-7777" },
]);
const engine = createEngine(store);
const privateEngine = engine as unknown as {
wireAutostashOrphanRecovery: (store: MockTaskStore) => void;
autostashOrphansHandler?: (data: { rootDir: string; records: Array<any> }) => Promise<void>;
};
privateEngine.wireAutostashOrphanRecovery(store);
await privateEngine.autostashOrphansHandler?.({
rootDir: "/tmp/project",
records: [
{
sha: "abcdef1234567",
ref: "stash@{0}",
label: "fusion-merger-autostash:FN-7777:finalize-reset:1",
sourceTaskId: "FN-7777",
createdAt: new Date().toISOString(),
changedPaths: ["a.ts"],
classification: "live",
sourcePhase: "finalize-reset",
detectedByTaskId: "FN-1234",
detectedAt: new Date().toISOString(),
},
],
});
await privateEngine.autostashOrphansHandler?.({
rootDir: "/tmp/project",
records: [
{
sha: "abcdef1234567",
ref: "stash@{0}",
label: "fusion-merger-autostash:FN-7777:finalize-reset:1",
sourceTaskId: "FN-7777",
createdAt: new Date().toISOString(),
changedPaths: ["a.ts"],
classification: "live",
sourcePhase: "finalize-reset",
detectedByTaskId: "FN-1234",
detectedAt: new Date().toISOString(),
},
],
});
expect(store.createTask).toHaveBeenCalledTimes(1);
});
it("uses default retry interval when interval settings retrieval fails", async () => { it("uses default retry interval when interval settings retrieval fails", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");

View File

@@ -62,16 +62,19 @@ describe("autostash orphan surface", () => {
expect(records[0]?.label).toContain("fusion-merger-autostash:FN-2001"); expect(records[0]?.label).toContain("fusion-merger-autostash:FN-2001");
}); });
it("parses sourceTaskId and createdAt; malformed labels return null fields", async () => { it("parses sourceTaskId, createdAt, and source phase; malformed labels return null fields", async () => {
const ts = Date.now(); const ts = Date.now();
createAutostash(dir, `fusion-merger-autostash:FN-2002:${ts}`, "a\n"); createAutostash(dir, `fusion-merger-autostash:FN-2002:${ts}`, "a\n");
createAutostash(dir, `fusion-merger-autostash:FN-2002:finalize-reset:${ts + 1}`, "phase\n");
createAutostash(dir, "fusion-merger-autostash:FN-2003:not-a-ts", "b\n"); createAutostash(dir, "fusion-merger-autostash:FN-2003:not-a-ts", "b\n");
const records = await listAutostashOrphans(dir); const records = await listAutostashOrphans(dir);
const good = records.find((r) => r.sourceTaskId === "FN-2002"); const good = records.find((r) => r.sourceTaskId === "FN-2002" && r.sourcePhase === "pre-merge");
const bad = records.find((r) => r.label.includes("not-a-ts")); const bad = records.find((r) => r.label.includes("not-a-ts"));
const phased = records.find((r) => r.label.includes("finalize-reset"));
expect(good?.createdAt).toBe(new Date(ts).toISOString()); expect(good?.createdAt).toBe(new Date(ts).toISOString());
expect(phased?.sourcePhase).toBe("finalize-reset");
expect(bad?.sourceTaskId).toBe("FN-2003"); expect(bad?.sourceTaskId).toBe("FN-2003");
expect(bad?.createdAt).toBeNull(); expect(bad?.createdAt).toBeNull();
}); });
@@ -113,11 +116,14 @@ describe("autostash orphan surface", () => {
expect(git(dir, 'git stash list --format="%H %s"')).toContain(sha); expect(git(dir, 'git stash list --format="%H %s"')).toContain(sha);
}); });
it("emits merger:autostashOrphans event with records payload", async () => { it("emits merger:autostashOrphans event with provenance payload", async () => {
createAutostash(dir, `fusion-merger-autostash:FN-2008:${Date.now()}`, "emit\n"); createAutostash(dir, `fusion-merger-autostash:FN-2008:${Date.now()}`, "emit\n");
const store = { emit: vi.fn() } as any; const store = { emit: vi.fn() } as any;
const records = await notifyAutostashOrphans(store, dir); const records = await notifyAutostashOrphans(store, dir, { detectedByTaskId: "FN-MERGE" });
expect(records[0]?.detectedByTaskId).toBe("FN-MERGE");
expect(records[0]?.detectedAt).toMatch(/T/);
expect(records).toHaveLength(1); expect(records).toHaveLength(1);
expect(store.emit).toHaveBeenCalledWith("merger:autostashOrphans", { expect(store.emit).toHaveBeenCalledWith("merger:autostashOrphans", {

View File

@@ -7776,6 +7776,54 @@ describe("commitOrAmendMergeWithFixes", () => {
expect(mockedExecSync.mock.calls.some((call) => String(call[0]) === "git merge-base --is-ancestor def456 abc123")).toBe(true); expect(mockedExecSync.mock.calls.some((call) => String(call[0]) === "git merge-base --is-ancestor def456 abc123")).toBe(true);
}); });
it("persists dirty leftovers before finalize reset in no-content fallback path", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff -z --cached --name-only")) return "" as any;
if (cmdStr.includes("git diff -z --name-only")) return "orphan.txt\0" as any;
if (cmdStr.includes("git diff --cached --name-only")) return "" as any;
if (cmdStr === "git diff --name-only") return "orphan.txt" as any;
if (cmdStr.includes("git status -z --porcelain")) return "" as any;
if (cmdStr === "git rev-parse HEAD") return "abc123" as any;
if (cmdStr === "git rev-parse fusion/fn-9999") return "def456" as any;
if (cmdStr === "git merge-base def456 abc123") return "zzz999" as any;
if (cmdStr === "git diff --stat abc123..fusion/fn-9999") return "" as any;
if (cmdStr === "git ls-files --others --exclude-standard") return "" as any;
if (cmdStr.includes("git log -1 --pretty=%B HEAD")) return "commit message without trailer" as any;
if (cmdStr === "git merge-base --is-ancestor def456 abc123") throw new Error("not ancestor");
if (cmdStr === "git add -A") return "" as any;
if (cmdStr === "git stash create") return "ff00aa" as any;
if (cmdStr.startsWith("git stash store -m")) return "" as any;
if (cmdStr === "git reset") return "" as any;
if (cmdStr === "git reset --hard abc123") return "" as any;
if (cmdStr === "git clean -fd") return "" as any;
if (cmdStr === "git merge --squash fusion/fn-9999") return "Already up to date." as any;
return "" as any;
});
const store = createMockStore();
const result = await commitOrAmendMergeWithFixes(
"/tmp/root",
"FN-9999",
"fusion/fn-9999",
"",
true,
"abc123",
"",
undefined,
DEFAULT_SETTINGS,
undefined,
null,
null,
new Set(),
store,
);
expect(result).toEqual({ ok: true, reason: "branch-already-merged" });
expect(mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git stash store -m"))).toBe(true);
expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls.some((call: any[]) => String(call[1]).includes("before finalize reset/amend cleanup"))).toBe(true);
});
it("treats squash-restore 'Already up to date' with no staged changes as already-merged success", async () => { it("treats squash-restore 'Already up to date' with no staged changes as already-merged success", async () => {
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);

View File

@@ -51,6 +51,7 @@ import {
type CanonicalMergeConflictStrategy, type CanonicalMergeConflictStrategy,
type TaskSourceIssue, type TaskSourceIssue,
type Task, type Task,
type AutostashOrphanRecord,
} from "@fusion/core"; } from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js"; import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -1042,7 +1043,7 @@ interface AutostashHandle {
} }
const AUTOSTASH_LABEL_PREFIX = "fusion-merger-autostash:"; const AUTOSTASH_LABEL_PREFIX = "fusion-merger-autostash:";
const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:race-rescue-\d+:)?(\d+)$/; const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:(?:[a-z0-9-]+:)?(?:\d+:)?)?(\d+)$/;
/** Return the set of paths a stash commit recorded as changed against its /** Return the set of paths a stash commit recorded as changed against its
* parent (HEAD-at-stash-time). Used to compare a new dirty snapshot against * parent (HEAD-at-stash-time). Used to compare a new dirty snapshot against
@@ -1274,15 +1275,6 @@ function parseAutostashTaskId(label: string): string | null {
return match?.[1] ?? null; return match?.[1] ?? null;
} }
export interface AutostashOrphanRecord {
sha: string;
ref: string;
label: string;
sourceTaskId: string | null;
createdAt: string | null;
changedPaths: string[];
classification: "subsumed" | "live" | "unknown";
}
function parseAutostashCreatedAt(label: string): string | null { function parseAutostashCreatedAt(label: string): string | null {
const match = AUTOSTASH_TIMESTAMP_RE.exec(label.trim()); const match = AUTOSTASH_TIMESTAMP_RE.exec(label.trim());
@@ -1292,6 +1284,15 @@ function parseAutostashCreatedAt(label: string): string | null {
return new Date(ts).toISOString(); return new Date(ts).toISOString();
} }
function parseAutostashSourcePhase(label: string): string | null {
const trimmed = label.trim();
const phaseMatch = /^fusion-merger-autostash:[A-Za-z]+-\d+:([a-z-]+):\d+$/.exec(trimmed);
if (phaseMatch?.[1]) return phaseMatch[1];
if (/^fusion-merger-autostash:[A-Za-z]+-\d+:race-rescue-\d+:\d+$/.test(trimmed)) return "race-rescue";
if (/^fusion-merger-autostash:[A-Za-z]+-\d+:\d+$/.test(trimmed)) return "pre-merge";
return null;
}
async function classifyAutostashOrphan(rootDir: string, sha: string): Promise<"subsumed" | "live" | "unknown"> { async function classifyAutostashOrphan(rootDir: string, sha: string): Promise<"subsumed" | "live" | "unknown"> {
try { try {
const stashFiles = await listStashChangedPaths(rootDir, sha); const stashFiles = await listStashChangedPaths(rootDir, sha);
@@ -1320,13 +1321,25 @@ export async function listAutostashOrphans(rootDir: string): Promise<AutostashOr
createdAt: parseAutostashCreatedAt(orphan.label), createdAt: parseAutostashCreatedAt(orphan.label),
changedPaths, changedPaths,
classification: await classifyAutostashOrphan(rootDir, orphan.sha), classification: await classifyAutostashOrphan(rootDir, orphan.sha),
sourcePhase: parseAutostashSourcePhase(orphan.label),
detectedByTaskId: null,
detectedAt: null,
}); });
} }
return records; return records;
} }
export async function notifyAutostashOrphans(store: TaskStore, rootDir: string): Promise<AutostashOrphanRecord[]> { export async function notifyAutostashOrphans(
const records = await listAutostashOrphans(rootDir); store: TaskStore,
rootDir: string,
options?: { detectedByTaskId?: string | null; detectedAt?: string },
): Promise<AutostashOrphanRecord[]> {
const detectedAt = options?.detectedAt ?? new Date().toISOString();
const records = (await listAutostashOrphans(rootDir)).map((record) => ({
...record,
detectedByTaskId: options?.detectedByTaskId ?? null,
detectedAt,
}));
store.emit("merger:autostashOrphans", { rootDir, records }); store.emit("merger:autostashOrphans", { rootDir, records });
return records; return records;
} }
@@ -1539,7 +1552,7 @@ async function sweepAutostashOrphans(
.catch(() => undefined); .catch(() => undefined);
} }
await notifyAutostashOrphans(store, rootDir).catch(() => undefined); await notifyAutostashOrphans(store, rootDir, { detectedByTaskId: taskId }).catch(() => undefined);
} }
export async function sweepStaleAutostashes( export async function sweepStaleAutostashes(
@@ -1574,6 +1587,8 @@ export async function sweepStaleAutostashes(
} }
} }
export type { AutostashOrphanRecord };
export const __test__ = { export const __test__ = {
sweepAutostashOrphans, sweepAutostashOrphans,
parseAutostashTaskId, parseAutostashTaskId,
@@ -2800,6 +2815,37 @@ type MergeFinalizeResult =
| { ok: true; reason: "completed" | "head-task-trailer" | "branch-already-merged" } | { ok: true; reason: "completed" | "head-task-trailer" | "branch-already-merged" }
| { ok: false; reason: "fix-produced-no-content" | "unknown-phantom" }; | { ok: false; reason: "fix-produced-no-content" | "unknown-phantom" };
async function persistFinalizeResetLeftovers(rootDir: string, taskId: string, store?: TaskStore): Promise<void> {
try {
const dirtyPaths = [...(await snapshotDirtyFiles(rootDir))];
if (dirtyPaths.length === 0) return;
await execAsync("git add -A", { cwd: rootDir });
const { stdout: createOut } = await execAsync("git stash create", { cwd: rootDir, encoding: "utf-8" });
const sha = String(createOut).trim();
if (!sha) {
await execAsync("git reset", { cwd: rootDir }).catch(() => undefined);
return;
}
const label = `${AUTOSTASH_LABEL_PREFIX}${taskId}:finalize-reset:${Date.now()}`;
await execAsync(`git stash store -m ${quoteArg(label)} ${sha}`, { cwd: rootDir });
await execAsync("git reset", { cwd: rootDir }).catch(() => undefined);
mergerLog.warn(
`${taskId}: persisted ${dirtyPaths.length} dirty rootDir path(s) before finalize reset as ${sha.slice(0, 7)} (${label})`,
);
if (store) {
await store.logEntry(
taskId,
`Persisted ${dirtyPaths.length} dirty rootDir path(s) before finalize reset/amend cleanup`,
`stash: ${sha}\nlabel: ${label}\nphase: finalize-reset\npaths:\n${dirtyPaths.join("\n")}`,
).catch(() => undefined);
await notifyAutostashOrphans(store, rootDir, { detectedByTaskId: taskId }).catch(() => undefined);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to persist dirty rootDir leftovers before finalize reset: ${msg}`);
}
}
export async function commitOrAmendMergeWithFixes( export async function commitOrAmendMergeWithFixes(
rootDir: string, rootDir: string,
taskId: string, taskId: string,
@@ -2814,6 +2860,7 @@ export async function commitOrAmendMergeWithFixes(
aiSummary?: string | null, aiSummary?: string | null,
aiSubject?: string | null, aiSubject?: string | null,
fixModifiedFiles: ReadonlySet<string> = new Set(), fixModifiedFiles: ReadonlySet<string> = new Set(),
store?: TaskStore,
): Promise<MergeFinalizeResult> { ): Promise<MergeFinalizeResult> {
try { try {
// Build an allowlist of paths we are permitted to stage. // Build an allowlist of paths we are permitted to stage.
@@ -2998,6 +3045,7 @@ export async function commitOrAmendMergeWithFixes(
// squash from branch -> preAttemptHeadSha and continue normally. // squash from branch -> preAttemptHeadSha and continue normally.
let squashRestoreReportedUpToDate = false; let squashRestoreReportedUpToDate = false;
try { try {
await persistFinalizeResetLeftovers(rootDir, taskId, store);
await execAsync(`git reset --hard ${preAttemptHeadSha}`, { await execAsync(`git reset --hard ${preAttemptHeadSha}`, {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
@@ -5412,6 +5460,7 @@ export async function aiMergeTask(
aiMergeSummary, aiMergeSummary,
aiMergeSubject, aiMergeSubject,
verificationFixModifiedFiles, verificationFixModifiedFiles,
store,
); );
if (!finalized.ok) { if (!finalized.ok) {
// Phantom-merge guard: refused to fabricate a commit. Reset // Phantom-merge guard: refused to fabricate a commit. Reset
@@ -5533,6 +5582,7 @@ export async function aiMergeTask(
aiMergeSummary, aiMergeSummary,
aiMergeSubject, aiMergeSubject,
buildFixModifiedFiles, buildFixModifiedFiles,
store,
); );
if (!finalized.ok) { if (!finalized.ok) {
// Phantom-merge guard: the verification fix passed but no // Phantom-merge guard: the verification fix passed but no

View File

@@ -5,6 +5,7 @@ import type {
CentralCore, CentralCore,
Settings, Settings,
MergeResult, MergeResult,
AutostashOrphanRecord,
AutomationStore as AutomationStoreType, AutomationStore as AutomationStoreType,
ScheduledTask, ScheduledTask,
AutomationRunResult, AutomationRunResult,
@@ -203,6 +204,7 @@ export class ProjectEngine {
private settingsHandlers: Array<(...args: any[]) => void> = []; private settingsHandlers: Array<(...args: any[]) => void> = [];
private taskMovedHandler?: (...args: any[]) => void; private taskMovedHandler?: (...args: any[]) => void;
private taskUpdatedHandler?: (...args: any[]) => void; private taskUpdatedHandler?: (...args: any[]) => void;
private autostashOrphansHandler?: (...args: any[]) => void;
constructor( constructor(
private config: ProjectRuntimeConfig, private config: ProjectRuntimeConfig,
@@ -438,6 +440,7 @@ export class ProjectEngine {
// 6. Wire auto-merge on task:moved and task:updated pause interruptions // 6. Wire auto-merge on task:moved and task:updated pause interruptions
this.wireAutoMerge(store, cwd); this.wireAutoMerge(store, cwd);
this.wireTaskPauseMergeInterruption(store); this.wireTaskPauseMergeInterruption(store);
this.wireAutostashOrphanRecovery(store);
// 7. Auto-merge startup sweep // 7. Auto-merge startup sweep
await this.startupMergeSweep(store); await this.startupMergeSweep(store);
@@ -513,6 +516,9 @@ export class ProjectEngine {
if (this.taskUpdatedHandler) { if (this.taskUpdatedHandler) {
store.off("task:updated", this.taskUpdatedHandler); store.off("task:updated", this.taskUpdatedHandler);
} }
if (this.autostashOrphansHandler) {
store.off("merger:autostashOrphans", this.autostashOrphansHandler as any);
}
} catch { } catch {
// Store may not be initialized if start() failed partway // Store may not be initialized if start() failed partway
} }
@@ -1832,6 +1838,38 @@ export class ProjectEngine {
store.on("task:moved", this.taskMovedHandler); store.on("task:moved", this.taskMovedHandler);
} }
private wireAutostashOrphanRecovery(store: TaskStore): void {
this.autostashOrphansHandler = async ({ records }: { rootDir: string; records: AutostashOrphanRecord[] }) => {
const liveRecords = records.filter((record) => record.classification === "live");
for (const record of liveRecords) {
const parentTaskId = record.sourceTaskId;
if (!parentTaskId) continue;
try {
const existingFollowUp = await this.findActiveRecoveryFollowUp(store, parentTaskId);
if (existingFollowUp) continue;
const sourcePhase = record.sourcePhase ?? "unknown";
await store.createTask({
description:
`Investigate preserved merger autostash leftover from ${parentTaskId} (${record.sha.slice(0, 7)}). ` +
`Detected by ${record.detectedByTaskId ?? "merge sweep"} during ${sourcePhase}; ` +
`stash label: ${record.label}. Recover from stash-recovery before dropping.`,
sourceType: "recovery",
sourceParentTaskId: parentTaskId,
} as any);
await store.logEntry(
parentTaskId,
`Auto-created recovery follow-up for live autostash orphan ${record.sha.slice(0, 7)}`,
`detectedBy=${record.detectedByTaskId ?? "unknown"}; phase=${sourcePhase}; stash=${record.label}`,
).catch(() => undefined);
} catch (err: unknown) {
runtimeLog.warn(`Autostash orphan recovery follow-up failed for ${parentTaskId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
};
store.on("merger:autostashOrphans", this.autostashOrphansHandler as any);
}
private wireTaskPauseMergeInterruption(store: TaskStore): void { private wireTaskPauseMergeInterruption(store: TaskStore): void {
this.taskUpdatedHandler = async (task: Task) => { this.taskUpdatedHandler = async (task: Task) => {
if (task.column !== "in-review") { if (task.column !== "in-review") {