Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions server/appRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ async function waitForCondition(
test("runtime lists active and archived PRs separately", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand All @@ -86,6 +87,7 @@ test("runtime lists active and archived PRs separately", async () => {
test("runtime queueBabysit enqueues a babysit job using the configured agent", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -116,6 +118,7 @@ test("runtime queueBabysit enqueues a babysit job using the configured agent", a
test("runtime queueBabysit records durable PR work intent", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -154,6 +157,7 @@ test("runtime queueBabysit records durable PR work intent", async () => {
test("runtime activity preserves monitor follow-up labels", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand All @@ -180,6 +184,7 @@ test("runtime activity preserves monitor follow-up labels", async () => {
test("runtime queueBabysit uses repo agent override when configured", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand All @@ -201,6 +206,7 @@ test("runtime queueBabysit uses repo agent override when configured", async () =
test("runtime exposes the latest PR agent run status", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -237,6 +243,7 @@ test("runtime exposes the latest PR agent run status", async () => {
test("runtime setWatchEnabled updates the PR and emits a change event", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand All @@ -263,6 +270,7 @@ test("runtime setWatchEnabled updates the PR and emits a change event", async ()
test("runtime setDrainMode logs enable and disable transitions", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -305,6 +313,7 @@ test("runtime clears stale CLI-missing drain mode once the agent command is avai

const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand All @@ -329,6 +338,7 @@ test("runtime clears stale CLI-missing drain mode once the agent command is avai
test("runtime askQuestion persists the question and enqueues a durable job", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -357,6 +367,7 @@ test("runtime askQuestion persists the question and enqueues a durable job", asy
test("runtime updateConfig persists updates and exposes them through getConfig", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -387,6 +398,7 @@ test("runtime updateConfig persists updates and exposes them through getConfig",
test("manual sync runs immediately even when global manual mode is on", async () => {
const storage = new MemStorage();
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -420,6 +432,7 @@ test("manual sync can target only PRs or only issues", async () => {
},
};
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -464,6 +477,7 @@ test("automatic watcher does not sync issues when issue automation is off", asyn
},
};
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -500,6 +514,7 @@ test("automatic watcher runs issue sync without PR sync when PR automation is of
},
};
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -537,6 +552,7 @@ test("automatic watcher does nothing when PR and issue automation are both off",
},
};
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -575,6 +591,7 @@ test("automatic watcher can run PR and issue automation together", async () => {
},
};
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1012,6 +1029,7 @@ test("syncRepos skips the issue sweep for a repo whose issue list responds 304",
};

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1050,6 +1068,7 @@ test("listIssueCoverage reads persisted counts and does not call GitHub", async
});

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1089,6 +1108,7 @@ test("syncRepos 304 probe does not fetch a GitHub open-issue count", async () =>
};

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1138,6 +1158,7 @@ test("syncRepos syncs issues and persists the new etag when the issue list chang
};

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1181,6 +1202,7 @@ test("listIssues stays cached-only when issue automation is off", async () => {

let buildOctokitCalls = 0;
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1234,6 +1256,7 @@ test("listIssues excludes closed worked issues from the default open issue count
], "2026-05-03T19:00:00.000Z");

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1266,6 +1289,7 @@ test("getIssue stays cached-only when issue automation is off", async () => {

let buildOctokitCalls = 0;
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1338,6 +1362,7 @@ test("syncIssue refreshes worked issue metadata from GitHub", async () => {
};

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1393,6 +1418,7 @@ test("runtime exposes queued issue work as current run status", async () => {
};

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand All @@ -1419,6 +1445,7 @@ test("pickWatcherColdStartDelayMs stays within the 15-45s cold-start window", ()
test("start() defers the first watcher tick instead of firing it during start", async () => {
let watcherRuns = 0;
const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage: new MemStorage(),
startBackgroundServices: false,
startWatcher: true,
Expand Down Expand Up @@ -1447,6 +1474,7 @@ test("syncRepos persists an issue-sweep backoff when the probe fails", async ()
};

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1482,6 +1510,7 @@ test("syncRepos skips an issue sweep for a repo whose persisted backoff is activ
};

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down Expand Up @@ -1510,6 +1539,7 @@ test("syncRepos defers the next sweep for a repo whose issue list is unchanged",
};

const runtime = createAppRuntime({
reclaimOrphanedWorktreesFn: async () => [],
storage,
startBackgroundServices: false,
startWatcher: false,
Expand Down
37 changes: 36 additions & 1 deletion server/appRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ import type { IStorage } from "./storage";
import { getDefaultStorage } from "./storage";
import { PRBabysitter } from "./babysitter";
import { resolveRepoAgentRuntimeSettings, resolveRepoCodingAgent } from "./agentSettings";
import { commandExists, detectAgentUnavailability, type AgentUnavailabilityKind, type CodingAgent } from "./agentRunner";
import { commandExists, detectAgentUnavailability, runCommand, type AgentUnavailabilityKind, type CodingAgent } from "./agentRunner";
import { planFailedJobRecovery } from "./failureRecovery";
import { reclaimOrphanedWorktrees } from "./repoWorkspace";
import { applyEvaluationDecision, applyFlagDecision } from "./feedbackLifecycle";
import { applyManualFeedbackDecision } from "./manualFeedback";
import { childLogger } from "./logger";
Expand Down Expand Up @@ -112,6 +113,7 @@ export type AppRuntimeDependencies = {
babysitter?: PRBabysitter;
watcherScheduler?: WatcherScheduler;
buildOctokitFn?: typeof buildOctokit;
reclaimOrphanedWorktreesFn?: typeof reclaimOrphanedWorktrees;
startBackgroundServices?: boolean;
startWatcher?: boolean;
};
Expand Down Expand Up @@ -940,6 +942,7 @@ export function mapMergedPullsToReleaseSummaries(pulls: MergedPRSummary[]): Rele
export function createAppRuntime(dependencies: AppRuntimeDependencies = {}): AppRuntime {
const storage = dependencies.storage ?? getDefaultStorage();
const buildOctokitImpl = dependencies.buildOctokitFn ?? buildOctokit;
const reclaimOrphanedWorktreesImpl = dependencies.reclaimOrphanedWorktreesFn ?? reclaimOrphanedWorktrees;
const events = new EventEmitter();
const socialPostJobs = new Map<string, ReleaseSocialPost>();
const backgroundJobQueue = dependencies.backgroundJobQueue ?? new BackgroundJobQueue(storage);
Expand Down Expand Up @@ -1053,6 +1056,34 @@ export function createAppRuntime(dependencies: AppRuntimeDependencies = {}): App
},
});

/**
* Sweep worktrees stranded by runs that died without unwinding their cleanup.
*
* `includeActive` is only safe at startup, where the single-instance lock means
* nothing can legitimately hold a worktree. On the watcher tick we reclaim only
* what this process is not actively using.
*/
const sweepOrphanedWorktrees = async (options: { includeActive?: boolean } = {}) => {
try {
const reclaimed = await reclaimOrphanedWorktreesImpl({
runCommand,
includeActive: options.includeActive,
});

for (const entry of reclaimed) {
log.info(
{ repoCacheDir: entry.repoCacheDir, reclaimed: entry.reclaimed },
"Reclaimed orphaned git worktrees left by an interrupted run",
);
}
} catch (error) {
log.warn(
{ err: error instanceof Error ? error.message : String(error) },
"Orphaned worktree sweep failed",
);
}
};

/**
* Revive parked background jobs whose park interval has elapsed, and un-park
* PRs whose work is actually still in flight. Without this, a job that ran out
Expand Down Expand Up @@ -1132,6 +1163,7 @@ export function createAppRuntime(dependencies: AppRuntimeDependencies = {}): App
// automation running unattended: whatever blocked a job may well have been
// fixed since, and nothing else tells us that it was.
await recoverParkedWork();
await sweepOrphanedWorktrees();

const rateLimit = getRateLimitState("core");
if (rateLimit.limited && rateLimit.resetAt) {
Expand Down Expand Up @@ -2313,6 +2345,9 @@ export function createAppRuntime(dependencies: AppRuntimeDependencies = {}): App
}

if (startWatcher) {
// Runs interrupted by the last shutdown cannot have cleaned up after
// themselves, so clear their worktrees before anything claims a new one.
await sweepOrphanedWorktrees({ includeActive: true });
await refreshWatcherSchedule();
void babysitter.resumeInterruptedRuns();
watcherColdStartTimer = setTimeout(() => {
Expand Down
Loading
Loading