feat(FN-3827): sweep stale autostashes after merge completion

Merger now properly cleans up autostash git refs that survive past their useful lifetime, fixing race conditions during restore/cleanup and adding scheduled stale-sweep runs in the engine. The feature includes docs for the autostash lifecycle, new test coverage for the cleanup and sweep paths, and a

Fusion-Task-Id: FN-3827
This commit is contained in:
Fusion
2026-05-09 18:41:50 -07:00
committed by gsxdsm
parent 8fa4f42ad7
commit 6240afe52f
9 changed files with 355 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix merger autostash lifecycle cleanup to drop primary and race-rescue stashes on terminal paths, and add startup/periodic stale autostash sweeping with a configurable max-age threshold.

View File

@@ -1245,6 +1245,12 @@ Git dashboard routes are registered in `register-git-github.ts`.
- `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior) - `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior)
- Supports workflow-step execution after merge (post-merge phase) - Supports workflow-step execution after merge (post-merge phase)
#### 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).
- 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.
- `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).
### Conflict handling ### Conflict handling
`merger.ts` includes conflict classification and auto-resolution helpers: `merger.ts` includes conflict classification and auto-resolution helpers:
- lock files (`LOCKFILE_PATTERNS`) - lock files (`LOCKFILE_PATTERNS`)

View File

@@ -205,6 +205,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `defaultPresetBySize` | `{ S?: string; M?: string; L?: string }` | `{}` | Mapping for `S`/`M`/`L` → preset ID. | | `defaultPresetBySize` | `{ S?: string; M?: string; L?: string }` | `{}` | Mapping for `S`/`M`/`L` → preset ID. |
| `autoResolveConflicts` | `boolean` | `true` | Enable automatic merge conflict resolution. | | `autoResolveConflicts` | `boolean` | `true` | Enable automatic merge conflict resolution. |
| `smartConflictResolution` | `boolean` | `true` | Alias/preferred flag for smart conflict handling. | | `smartConflictResolution` | `boolean` | `true` | Alias/preferred flag for smart conflict handling. |
| `mergerAutostashMaxAgeHours` | `number` | `24` | Maximum autostash age in hours before startup/periodic stale-stash sweep drops `fusion-merger-autostash:*` leftovers (minimum `1`). |
| `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. | | `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. |
| `buildRetryCount` | `number` | `0` | Build retry attempts during merge. | | `buildRetryCount` | `number` | `0` | Build retry attempts during merge. |
| `verificationFixRetries` | `number` | `2` | Auto-fix retry attempts when verification fails during merge. | | `verificationFixRetries` | `number` | `2` | Auto-fix retry attempts when verification fails during merge. |

View File

@@ -201,6 +201,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
defaultPresetBySize: {}, defaultPresetBySize: {},
autoResolveConflicts: true, autoResolveConflicts: true,
smartConflictResolution: true, smartConflictResolution: true,
mergerAutostashMaxAgeHours: 24,
worktreeRebaseBeforeMerge: true, worktreeRebaseBeforeMerge: true,
worktreeRebaseRemote: "", worktreeRebaseRemote: "",
worktreeRebaseLocalBase: true, worktreeRebaseLocalBase: true,

View File

@@ -2072,6 +2072,8 @@ export interface ProjectSettings {
* lock files (ours), generated files (theirs), and trivial whitespace conflicts * lock files (ours), generated files (theirs), and trivial whitespace conflicts
* without spawning an AI agent. Default: true. */ * without spawning an AI agent. Default: true. */
smartConflictResolution?: boolean; smartConflictResolution?: boolean;
/** Drop stale merger autostashes older than this age in hours. Minimum 1. Default: 24. */
mergerAutostashMaxAgeHours?: number;
/** When true, the merger fetches the remote and rebases the task branch /** When true, the merger fetches the remote and rebases the task branch
* onto the latest `<remote>/<defaultBranch>` before attempting to merge * onto the latest `<remote>/<defaultBranch>` before attempting to merge
* it back into the main branch. This catches upstream changes from * it back into the main branch. This catches upstream changes from

View File

@@ -6,7 +6,7 @@ import { execSync } from "node:child_process";
import type { TaskStore } from "@fusion/core"; import type { TaskStore } from "@fusion/core";
import { __test__ } from "../merger.js"; import { __test__ } from "../merger.js";
const { sweepAutostashOrphans, parseAutostashTaskId } = __test__; const { sweepAutostashOrphans, parseAutostashTaskId, sweepStaleAutostashes, dropAutostashHandle } = __test__;
function git(cwd: string, cmd: string): string { function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim(); return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim();
@@ -65,6 +65,82 @@ describe("parseAutostashTaskId", () => {
}); });
}); });
describe("sweepStaleAutostashes", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fn-autostash-stale-"));
initRepo(dir);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("drops stale autostashes older than maxAgeMs", async () => {
const oldTs = Date.now() - 26 * 60 * 60 * 1000;
const staleLabel = `fusion-merger-autostash:FN-5001:${oldTs}`;
writeFileSync(join(dir, "file.txt"), "stale\n");
git(dir, `git stash push -m ${JSON.stringify(staleLabel)} file.txt`);
const res = await sweepStaleAutostashes(dir, { maxAgeMs: 24 * 60 * 60 * 1000 });
expect(res.dropped).toBe(1);
expect(stashList(dir)).not.toContain("fusion-merger-autostash:FN-5001");
});
it("keeps recent autostashes within maxAgeMs", async () => {
const freshLabel = `fusion-merger-autostash:FN-5002:${Date.now()}`;
writeFileSync(join(dir, "file.txt"), "fresh\n");
git(dir, `git stash push -m ${JSON.stringify(freshLabel)} file.txt`);
const res = await sweepStaleAutostashes(dir, { maxAgeMs: 24 * 60 * 60 * 1000 });
expect(res.dropped).toBe(0);
expect(stashList(dir)).toContain("fusion-merger-autostash:FN-5002");
});
it("ignores non-fusion stash labels", async () => {
writeFileSync(join(dir, "file.txt"), "manual\n");
git(dir, "git stash push -m \"manual\" file.txt");
const res = await sweepStaleAutostashes(dir, { maxAgeMs: 1 });
expect(res.dropped).toBe(0);
expect(stashList(dir)).toContain("manual");
});
it("tolerates malformed autostash labels", async () => {
const malformed = "fusion-merger-autostash:FN-5003:not-a-timestamp";
writeFileSync(join(dir, "file.txt"), "bad\n");
git(dir, `git stash push -m ${JSON.stringify(malformed)} file.txt`);
await expect(sweepStaleAutostashes(dir, { maxAgeMs: 1 })).resolves.toEqual({ dropped: 0 });
expect(stashList(dir)).toContain("not-a-timestamp");
});
it("dropAutostashHandle drops primary and rescue shas", async () => {
const pLabel = `fusion-merger-autostash:FN-5004:${Date.now() - 1000}`;
writeFileSync(join(dir, "file.txt"), "primary\n");
git(dir, `git stash push -m ${JSON.stringify(pLabel)} file.txt`);
const primarySha = git(dir, 'git stash list --format="%H" -n 1');
const rLabel = `fusion-merger-autostash:FN-5004:race-rescue-0:${Date.now() - 500}`;
writeFileSync(join(dir, "file.txt"), "rescue\n");
git(dir, `git stash push -m ${JSON.stringify(rLabel)} file.txt`);
const rescueSha = git(dir, 'git stash list --format="%H" -n 1');
const result = await dropAutostashHandle(dir, "FN-5004", {
sha: primarySha,
label: pLabel,
rescueShas: [{ sha: rescueSha, label: rLabel }],
}, { keepIfLive: false });
expect(result.dropped).toBe(2);
expect(stashList(dir)).toBe("");
});
});
describe("sweepAutostashOrphans", () => { describe("sweepAutostashOrphans", () => {
let dir: string; let dir: string;

View File

@@ -486,6 +486,61 @@ describe("aiMergeTask abort handling", () => {
}); });
}); });
describe("aiMergeTask autostash cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("drops task autostash after successful merge restore", async () => {
const store = createMockStore({ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" });
const stashSha = "1111111111111111111111111111111111111111";
let dropped = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes('git stash list --format="%H %gd %s"')) {
return dropped ? "" : `${stashSha} stash@{0} fusion-merger-autostash:FN-050:1`;
}
if (cmdStr.includes("git status -z --porcelain")) return " M file.txt\0" as any;
if (cmdStr.includes("git stash create")) return stashSha as any;
if (cmdStr.includes("git stash store")) return "" as any;
if (cmdStr.includes('git stash list --format="%H %gd"')) return dropped ? "" : `${stashSha} stash@{0}`;
if (cmdStr.includes("git rev-parse stash@{0}")) return stashSha as any;
if (cmdStr.includes("git stash drop stash@{0}")) {
dropped = true;
return "" as any;
}
if (cmdStr.includes("git stash apply")) return "" as any;
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(dropped).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes("git stash drop stash@{0}")),
).toBe(true);
});
});
describe("aiMergeTask — conditional worktree cleanup", () => { describe("aiMergeTask — conditional worktree cleanup", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -1448,9 +1448,46 @@ async function sweepAutostashOrphans(
} }
} }
const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:race-rescue-\d+:)?(\d+)$/;
export async function sweepStaleAutostashes(
rootDir: string,
options: { maxAgeMs: number; taskStore?: TaskStore },
): Promise<{ dropped: number }> {
try {
void options.taskStore;
const now = Date.now();
const threshold = Math.max(0, Math.trunc(options.maxAgeMs));
const entries = await listOrphanedAutostashes(rootDir);
let dropped = 0;
for (const entry of entries) {
const match = AUTOSTASH_TIMESTAMP_RE.exec(entry.label.trim());
if (!match) continue;
const ts = Number.parseInt(match[1] ?? "", 10);
if (!Number.isFinite(ts)) continue;
if (now - ts <= threshold) continue;
const sourceTaskId = parseAutostashTaskId(entry.label) ?? "autostash-sweep";
const result = await dropAutostashBySha(rootDir, sourceTaskId, entry.sha);
if (result.dropped) dropped += 1;
}
const hours = Math.max(1, Math.round(threshold / 3_600_000));
mergerLog.log(`startup-sweep: dropped ${dropped} stale fusion-merger-autostash entries older than ${hours}h`);
return { dropped };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`startup-sweep: stale autostash sweep failed (${msg})`);
return { dropped: 0 };
}
}
export const __test__ = { export const __test__ = {
sweepAutostashOrphans, sweepAutostashOrphans,
parseAutostashTaskId, parseAutostashTaskId,
dropAutostashHandle,
isAutostashLive,
sweepStaleAutostashes,
}; };
async function stashUnrelatedRootDirChanges( async function stashUnrelatedRootDirChanges(
@@ -1652,6 +1689,73 @@ async function dropAutostashBySha(
return { dropped: false, reason: "exhausted retry attempts" }; return { dropped: false, reason: "exhausted retry attempts" };
} }
async function isAutostashLive(rootDir: string, sha: string): Promise<boolean> {
try {
const stashFiles = await listStashChangedPaths(rootDir, sha);
if (stashFiles.size === 0) return false;
const pathsArg = [...stashFiles].map(quoteArg).join(" ");
const { stdout: pathDiffOut } = await execAsync(
`git diff --name-only HEAD ${quoteArg(sha)} -- ${pathsArg}`,
{ cwd: rootDir, encoding: "utf-8" },
);
return pathDiffOut.trim().length > 0;
} catch {
return true;
}
}
async function dropAutostashHandle(
rootDir: string,
taskId: string,
handle: AutostashHandle,
options: {
keepIfLive: boolean;
store?: TaskStore;
context?: string;
},
): Promise<{ dropped: number; keptLive: number; failed: number }> {
const entries = [
{ sha: handle.sha, label: handle.label, kind: "primary" as const },
...(handle.rescueShas ?? []).map((r) => ({ sha: r.sha, label: r.label, kind: "race-rescue" as const })),
];
let dropped = 0;
let keptLive = 0;
let failed = 0;
for (const entry of entries) {
if (options.keepIfLive) {
const live = await isAutostashLive(rootDir, entry.sha);
if (live) {
keptLive += 1;
mergerLog.warn(`${taskId}: preserving live ${entry.kind} autostash ${entry.sha.slice(0, 7)} (${entry.label})`);
continue;
}
}
const dropResult = await dropAutostashBySha(rootDir, taskId, entry.sha);
if (dropResult.dropped) {
dropped += 1;
mergerLog.log(`${taskId}: dropped ${entry.kind} autostash ${entry.sha.slice(0, 7)} (${entry.label})`);
} else {
failed += 1;
mergerLog.warn(
`${taskId}: failed to drop ${entry.kind} autostash ${entry.sha.slice(0, 7)} (${entry.label}) — ${dropResult.reason ?? "unknown"}`,
);
}
}
if (options.store && options.context) {
await options.store.logEntry(
taskId,
`${options.context}: autostash cleanup dropped ${dropped}, preserved ${keptLive} live, failed ${failed}`,
entries.map((entry) => `${entry.kind} ${entry.sha.slice(0, 7)} (${entry.label})`).join("\n"),
).catch(() => undefined);
}
return { dropped, keptLive, failed };
}
/** /**
* AI fix-agent for autostash apply conflicts. Spawned only when applying * AI fix-agent for autostash apply conflicts. Spawned only when applying
* the stashed dev work hits a conflict — the merge has already committed * the stashed dev work hits a conflict — the merge has already committed
@@ -2221,6 +2325,44 @@ ${fileList}
* `git apply --3way` from the patch, fall through to AI patch-recovery * `git apply --3way` from the patch, fall through to AI patch-recovery
* if needed. See `tryRecoverHardFailApply`. * if needed. See `tryRecoverHardFailApply`.
*/ */
async function restoreRescueAutostashes(
rootDir: string,
taskId: string,
handle: AutostashHandle,
ctx: {
store: TaskStore;
},
): Promise<{ unresolvedCount: number }> {
const rescueShas = handle.rescueShas ?? [];
if (rescueShas.length === 0) return { unresolvedCount: 0 };
let unresolvedCount = 0;
for (const rescue of rescueShas) {
try {
await execAsync(`git stash apply ${rescue.sha}`, { cwd: rootDir });
const dropResult = await dropAutostashBySha(rootDir, taskId, rescue.sha);
if (dropResult.dropped) {
mergerLog.log(`${taskId}: restored and dropped race-rescue autostash ${rescue.sha.slice(0, 7)} (${rescue.label})`);
} else {
unresolvedCount += 1;
mergerLog.warn(`${taskId}: restored race-rescue autostash ${rescue.sha.slice(0, 7)} but drop failed (${dropResult.reason ?? "unknown"})`);
}
} catch (err: unknown) {
unresolvedCount += 1;
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: race-rescue autostash apply failed for ${rescue.sha.slice(0, 7)} (${msg}); preserving stash for manual recovery`);
}
}
await ctx.store.logEntry(
taskId,
`Race-rescue autostash restore attempted: ${rescueShas.length - unresolvedCount} restored, ${unresolvedCount} preserved`,
rescueShas.map((r) => `${r.sha.slice(0, 7)} (${r.label})`).join("\n"),
).catch(() => undefined);
return { unresolvedCount };
}
async function restoreUnrelatedRootDirChanges( async function restoreUnrelatedRootDirChanges(
rootDir: string, rootDir: string,
taskId: string, taskId: string,
@@ -5727,11 +5869,28 @@ export async function aiMergeTask(
if (resultForFinally) { if (resultForFinally) {
resultForFinally.autostash = outcome; resultForFinally.autostash = outcome;
} }
const rescueRestore = outcome.status === "restored" || outcome.status === "ai-resolved"
? await restoreRescueAutostashes(rootDir, taskId, autostashHandle, { store })
: { unresolvedCount: 0 };
const keepIfLive = outcome.status === "failed"
|| outcome.status === "conflict-needs-manual"
|| rescueRestore.unresolvedCount > 0;
await dropAutostashHandle(rootDir, taskId, autostashHandle, {
keepIfLive,
store,
context: "Post-restore autostash cleanup",
});
} catch (err: unknown) { } catch (err: unknown) {
// Any throw from restore should never propagate out of the merger // Any throw from restore should never propagate out of the merger
// — the merge result has already been recorded. Log and swallow. // — the merge result has already been recorded. Log and swallow.
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: autostash restore threw unexpectedly (${msg}) — stash may be left in place; check git stash list`); mergerLog.warn(`${taskId}: autostash restore threw unexpectedly (${msg}) — running keep-if-live cleanup sweep`);
await dropAutostashHandle(rootDir, taskId, autostashHandle, {
keepIfLive: true,
store,
context: "Autostash restore exception cleanup",
});
if (resultForFinally) { if (resultForFinally) {
resultForFinally.autostash = { resultForFinally.autostash = {
status: "failed", status: "failed",

View File

@@ -21,7 +21,7 @@ import { NotificationService } from "./notification/index.js";
import { GridlockDetector } from "./gridlock-detector.js"; import { GridlockDetector } from "./gridlock-detector.js";
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
import type { RoutineRunner } from "./routine-runner.js"; import type { RoutineRunner } from "./routine-runner.js";
import { aiMergeTask } from "./merger.js"; import { aiMergeTask, sweepStaleAutostashes } from "./merger.js";
import { PRIORITY_MERGE } from "./concurrency.js"; import { PRIORITY_MERGE } from "./concurrency.js";
import { runtimeLog } from "./logger.js"; import { runtimeLog } from "./logger.js";
import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js"; import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js";
@@ -170,6 +170,7 @@ export class ProjectEngine {
private activeMergeTaskId: string | null = null; private activeMergeTaskId: string | null = null;
private mergeAbortController: AbortController | null = null; private mergeAbortController: AbortController | null = null;
private mergeRetryTimer: ReturnType<typeof setTimeout> | null = null; private mergeRetryTimer: ReturnType<typeof setTimeout> | null = null;
private autostashSweepTimer: ReturnType<typeof setTimeout> | null = null;
/** /**
* Pending manual merge resolvers — keyed by taskId. * Pending manual merge resolvers — keyed by taskId.
@@ -444,6 +445,10 @@ export class ProjectEngine {
// 8. Start periodic merge retry sweep // 8. Start periodic merge retry sweep
this.scheduleMergeRetry(store); this.scheduleMergeRetry(store);
// 9. Startup + periodic stale autostash sweeps (independent of autoMerge)
void this.runStaleAutostashSweep(store, "startup");
this.scheduleStaleAutostashSweep(store);
this.started = true; this.started = true;
runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`); runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`);
} }
@@ -467,6 +472,10 @@ export class ProjectEngine {
clearTimeout(this.mergeRetryTimer); clearTimeout(this.mergeRetryTimer);
this.mergeRetryTimer = null; this.mergeRetryTimer = null;
} }
if (this.autostashSweepTimer) {
clearTimeout(this.autostashSweepTimer);
this.autostashSweepTimer = null;
}
// Abort active/pending merge work before tearing down sessions. // Abort active/pending merge work before tearing down sessions.
this.mergeAbortController?.abort(); this.mergeAbortController?.abort();
@@ -1920,6 +1929,44 @@ export class ProjectEngine {
} }
} }
private resolveAutostashMaxAgeMs(settings: Settings): number {
const hours = Math.max(1, Math.trunc(settings.mergerAutostashMaxAgeHours ?? 24));
return hours * 60 * 60 * 1000;
}
private async runStaleAutostashSweep(store: TaskStore, reason: "startup" | "periodic"): Promise<void> {
try {
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused) return;
const maxAgeMs = this.resolveAutostashMaxAgeMs(settings);
const result = await sweepStaleAutostashes(this.config.workingDirectory, {
maxAgeMs,
taskStore: store,
});
if (result.dropped > 0) {
runtimeLog.log(`${reason === "startup" ? "Startup" : "Periodic"} stale autostash sweep dropped ${result.dropped} stash(es)`);
}
} catch (err: unknown) {
runtimeLog.warn(`Stale autostash ${reason} sweep failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
private scheduleStaleAutostashSweep(store: TaskStore): void {
if (this.shuttingDown) return;
const schedule = async () => {
if (this.shuttingDown) return;
try {
await this.runStaleAutostashSweep(store, "periodic");
} finally {
if (!this.shuttingDown) {
this.autostashSweepTimer = setTimeout(() => void schedule(), 60 * 60 * 1000);
}
}
};
this.autostashSweepTimer = setTimeout(() => void schedule(), 60 * 60 * 1000);
}
private scheduleMergeRetry(store: TaskStore): void { private scheduleMergeRetry(store: TaskStore): void {
if (this.shuttingDown) return; if (this.shuttingDown) return;