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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}>;
|
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user