@virtengine/openfleet 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/maintenance.mjs
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maintenance.mjs - Process hygiene and cleanup for openfleet.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Killing stale orchestrator processes from previous runs
|
|
6
|
+
* - Reaping stuck git push processes (>5 min)
|
|
7
|
+
* - Pruning broken/orphaned git worktrees
|
|
8
|
+
* - Monitor singleton enforcement via PID file
|
|
9
|
+
* - Periodic maintenance sweeps
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
pruneStaleWorktrees,
|
|
17
|
+
getWorktreeStats,
|
|
18
|
+
fixGitConfigCorruption,
|
|
19
|
+
} from "./worktree-manager.mjs";
|
|
20
|
+
|
|
21
|
+
const isWindows = process.platform === "win32";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get all running processes matching a filter.
|
|
25
|
+
* Returns [{pid, commandLine, creationDate}].
|
|
26
|
+
*/
|
|
27
|
+
function getProcesses(filter) {
|
|
28
|
+
if (!isWindows) {
|
|
29
|
+
// Linux/macOS: use ps
|
|
30
|
+
try {
|
|
31
|
+
const out = execSync(`ps -eo pid,lstart,args 2>/dev/null`, {
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
timeout: 10000,
|
|
34
|
+
});
|
|
35
|
+
const lines = out.trim().split("\n").slice(1);
|
|
36
|
+
return lines
|
|
37
|
+
.map((line) => {
|
|
38
|
+
const m = line.trim().match(/^(\d+)\s+(.+?\d{4})\s+(.+)$/);
|
|
39
|
+
if (!m) return null;
|
|
40
|
+
return {
|
|
41
|
+
pid: Number(m[1]),
|
|
42
|
+
creationDate: new Date(m[2]),
|
|
43
|
+
commandLine: m[3],
|
|
44
|
+
};
|
|
45
|
+
})
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Windows: use PowerShell to get process info (WMI is more reliable for CommandLine)
|
|
53
|
+
try {
|
|
54
|
+
const cmd = `Get-CimInstance Win32_Process ${filter ? `-Filter "${filter}"` : ""} | Select-Object ProcessId, CommandLine, CreationDate | ConvertTo-Json -Compress`;
|
|
55
|
+
const out = spawnSync("powershell", ["-NoProfile", "-Command", cmd], {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
timeout: 15000,
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
});
|
|
60
|
+
if (out.status !== 0 || !out.stdout.trim()) return [];
|
|
61
|
+
const parsed = JSON.parse(out.stdout);
|
|
62
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
63
|
+
return arr
|
|
64
|
+
.filter((p) => p && p.ProcessId)
|
|
65
|
+
.map((p) => ({
|
|
66
|
+
pid: p.ProcessId,
|
|
67
|
+
commandLine: p.CommandLine || "",
|
|
68
|
+
creationDate: p.CreationDate
|
|
69
|
+
? new Date(p.CreationDate.replace(/\/Date\((\d+)\)\//, "$1") * 1)
|
|
70
|
+
: null,
|
|
71
|
+
}));
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Kill a process by PID with force.
|
|
79
|
+
*/
|
|
80
|
+
function killPid(pid, label) {
|
|
81
|
+
try {
|
|
82
|
+
if (isWindows) {
|
|
83
|
+
spawnSync("taskkill", ["/F", "/PID", String(pid)], {
|
|
84
|
+
timeout: 5000,
|
|
85
|
+
windowsHide: true,
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
process.kill(pid, "SIGKILL");
|
|
89
|
+
}
|
|
90
|
+
console.log(`[maintenance] killed ${label || "process"} (PID ${pid})`);
|
|
91
|
+
return true;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// Process may already be gone
|
|
94
|
+
if (e.code !== "ESRCH") {
|
|
95
|
+
console.warn(`[maintenance] failed to kill PID ${pid}: ${e.message}`);
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Kill stale orchestrator processes (pwsh running ve-orchestrator.ps1).
|
|
103
|
+
* Skips our own child if childPid is provided.
|
|
104
|
+
*/
|
|
105
|
+
export function killStaleOrchestrators(childPid) {
|
|
106
|
+
const myPid = process.pid;
|
|
107
|
+
const procs = getProcesses("Name='pwsh.exe'");
|
|
108
|
+
let killed = 0;
|
|
109
|
+
|
|
110
|
+
for (const p of procs) {
|
|
111
|
+
if (p.pid === myPid || p.pid === childPid) continue;
|
|
112
|
+
if (p.commandLine && p.commandLine.includes("ve-orchestrator.ps1")) {
|
|
113
|
+
killPid(p.pid, "stale orchestrator");
|
|
114
|
+
killed++;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (killed > 0) {
|
|
119
|
+
console.log(
|
|
120
|
+
`[maintenance] killed ${killed} stale orchestrator process(es)`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return killed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Kill git push processes that have been running longer than maxAgeMs.
|
|
128
|
+
* Default: 5 minutes. These get stuck on network issues or lock contention.
|
|
129
|
+
*/
|
|
130
|
+
export function reapStuckGitPushes(maxAgeMs = 15 * 60 * 1000) {
|
|
131
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
132
|
+
const filterName = isWindows
|
|
133
|
+
? "Name='pwsh.exe' OR Name='git.exe' OR Name='bash.exe'"
|
|
134
|
+
: null;
|
|
135
|
+
const procs = getProcesses(filterName);
|
|
136
|
+
let killed = 0;
|
|
137
|
+
|
|
138
|
+
for (const p of procs) {
|
|
139
|
+
if (!p.commandLine) continue;
|
|
140
|
+
// Match git push commands (direct or via pwsh/bash wrappers)
|
|
141
|
+
const isGitPush =
|
|
142
|
+
p.commandLine.includes("git push") ||
|
|
143
|
+
p.commandLine.includes("git.exe push");
|
|
144
|
+
if (!isGitPush) continue;
|
|
145
|
+
|
|
146
|
+
// Check age
|
|
147
|
+
if (p.creationDate && p.creationDate.getTime() < cutoff) {
|
|
148
|
+
killPid(
|
|
149
|
+
p.pid,
|
|
150
|
+
`stuck git push (age ${Math.round((Date.now() - p.creationDate.getTime()) / 60000)}min)`,
|
|
151
|
+
);
|
|
152
|
+
killed++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (killed > 0) {
|
|
157
|
+
console.log(`[maintenance] reaped ${killed} stuck git push process(es)`);
|
|
158
|
+
}
|
|
159
|
+
return killed;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Prune broken git worktrees and remove orphaned temp directories.
|
|
164
|
+
*/
|
|
165
|
+
export function cleanupWorktrees(repoRoot) {
|
|
166
|
+
let pruned = 0;
|
|
167
|
+
|
|
168
|
+
// 1. `git worktree prune` removes entries whose directories no longer exist
|
|
169
|
+
try {
|
|
170
|
+
spawnSync("git", ["worktree", "prune"], {
|
|
171
|
+
cwd: repoRoot,
|
|
172
|
+
timeout: 15000,
|
|
173
|
+
windowsHide: true,
|
|
174
|
+
});
|
|
175
|
+
console.log("[maintenance] git worktree prune completed");
|
|
176
|
+
pruned++;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
console.warn(`[maintenance] git worktree prune failed: ${e.message}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2. List remaining worktrees and check for stale VK temp ones
|
|
182
|
+
try {
|
|
183
|
+
const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
184
|
+
cwd: repoRoot,
|
|
185
|
+
encoding: "utf8",
|
|
186
|
+
timeout: 10000,
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
});
|
|
189
|
+
if (result.stdout) {
|
|
190
|
+
const entries = result.stdout.split(/\n\n/).filter(Boolean);
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
const pathMatch = entry.match(/^worktree\s+(.+)/m);
|
|
193
|
+
if (!pathMatch) continue;
|
|
194
|
+
const wtPath = pathMatch[1].trim();
|
|
195
|
+
// Only touch vibe-kanban temp worktrees
|
|
196
|
+
if (!wtPath.includes("vibe-kanban") || wtPath === repoRoot) continue;
|
|
197
|
+
// Check if the path exists on disk
|
|
198
|
+
if (!existsSync(wtPath)) {
|
|
199
|
+
console.log(
|
|
200
|
+
`[maintenance] removing orphaned worktree entry: ${wtPath}`,
|
|
201
|
+
);
|
|
202
|
+
try {
|
|
203
|
+
spawnSync("git", ["worktree", "remove", "--force", wtPath], {
|
|
204
|
+
cwd: repoRoot,
|
|
205
|
+
timeout: 10000,
|
|
206
|
+
windowsHide: true,
|
|
207
|
+
});
|
|
208
|
+
pruned++;
|
|
209
|
+
} catch {
|
|
210
|
+
/* best effort */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
console.warn(`[maintenance] worktree list check failed: ${e.message}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 3. Clean up old copilot-worktree entries (older than 7 days)
|
|
220
|
+
try {
|
|
221
|
+
const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
222
|
+
cwd: repoRoot,
|
|
223
|
+
encoding: "utf8",
|
|
224
|
+
timeout: 10000,
|
|
225
|
+
windowsHide: true,
|
|
226
|
+
});
|
|
227
|
+
if (result.stdout) {
|
|
228
|
+
const entries = result.stdout.split(/\n\n/).filter(Boolean);
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
const pathMatch = entry.match(/^worktree\s+(.+)/m);
|
|
231
|
+
if (!pathMatch) continue;
|
|
232
|
+
const wtPath = pathMatch[1].trim();
|
|
233
|
+
if (wtPath === repoRoot) continue;
|
|
234
|
+
// copilot-worktree-YYYY-MM-DD format
|
|
235
|
+
const dateMatch = wtPath.match(/copilot-worktree-(\d{4}-\d{2}-\d{2})/);
|
|
236
|
+
if (!dateMatch) continue;
|
|
237
|
+
const wtDate = new Date(dateMatch[1]);
|
|
238
|
+
const ageMs = Date.now() - wtDate.getTime();
|
|
239
|
+
if (ageMs > 7 * 24 * 60 * 60 * 1000) {
|
|
240
|
+
console.log(`[maintenance] removing old copilot worktree: ${wtPath}`);
|
|
241
|
+
try {
|
|
242
|
+
spawnSync("git", ["worktree", "remove", "--force", wtPath], {
|
|
243
|
+
cwd: repoRoot,
|
|
244
|
+
timeout: 15000,
|
|
245
|
+
windowsHide: true,
|
|
246
|
+
});
|
|
247
|
+
pruned++;
|
|
248
|
+
} catch {
|
|
249
|
+
/* best effort */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
/* best effort */
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return pruned;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Stale Branch Cleanup ────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Clean up old local branches created by codex/vibe-kanban automation.
|
|
265
|
+
*
|
|
266
|
+
* Targets branches matching `ve/*` and `copilot-worktree-*` patterns.
|
|
267
|
+
*
|
|
268
|
+
* Safety checks before deleting a branch:
|
|
269
|
+
* 1. Not the currently checked-out branch
|
|
270
|
+
* 2. Not checked out in any active worktree
|
|
271
|
+
* 3. Has a corresponding remote branch (was pushed) OR has been merged
|
|
272
|
+
* 4. Local and remote refs match (no unpushed local commits)
|
|
273
|
+
* 5. Last commit is older than `minAgeMs` (default 24 hours)
|
|
274
|
+
*
|
|
275
|
+
* @param {string} repoRoot - repository root path
|
|
276
|
+
* @param {object} [opts]
|
|
277
|
+
* @param {number} [opts.minAgeMs=86400000] - minimum age in ms (default 24h)
|
|
278
|
+
* @param {boolean} [opts.dryRun=false] - if true, log but don't delete
|
|
279
|
+
* @param {string[]} [opts.protectedBranches] - branches to never delete (default: ["main","mainnet/main"])
|
|
280
|
+
* @param {string[]} [opts.patterns] - branch glob prefixes to target (default: ["ve/","copilot-worktree-"])
|
|
281
|
+
* @returns {{ deleted: string[], skipped: { branch: string, reason: string }[], errors: string[] }}
|
|
282
|
+
*/
|
|
283
|
+
export function cleanupStaleBranches(repoRoot, opts = {}) {
|
|
284
|
+
const {
|
|
285
|
+
minAgeMs = 24 * 60 * 60 * 1000,
|
|
286
|
+
dryRun = false,
|
|
287
|
+
protectedBranches = ["main", "mainnet/main"],
|
|
288
|
+
patterns = ["ve/", "copilot-worktree-"],
|
|
289
|
+
} = opts;
|
|
290
|
+
|
|
291
|
+
const result = { deleted: [], skipped: [], errors: [] };
|
|
292
|
+
if (!repoRoot) return result;
|
|
293
|
+
|
|
294
|
+
// 1. Get currently checked-out branch
|
|
295
|
+
let currentBranch = null;
|
|
296
|
+
try {
|
|
297
|
+
const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
298
|
+
cwd: repoRoot,
|
|
299
|
+
encoding: "utf8",
|
|
300
|
+
timeout: 5000,
|
|
301
|
+
windowsHide: true,
|
|
302
|
+
});
|
|
303
|
+
if (r.status === 0) currentBranch = r.stdout.trim();
|
|
304
|
+
} catch {
|
|
305
|
+
/* best effort */
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. Get branches checked out in worktrees (cannot delete these)
|
|
309
|
+
const worktreeBranches = new Set();
|
|
310
|
+
try {
|
|
311
|
+
const r = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
312
|
+
cwd: repoRoot,
|
|
313
|
+
encoding: "utf8",
|
|
314
|
+
timeout: 10000,
|
|
315
|
+
windowsHide: true,
|
|
316
|
+
});
|
|
317
|
+
if (r.status === 0 && r.stdout) {
|
|
318
|
+
for (const entry of r.stdout.split(/\n\n/).filter(Boolean)) {
|
|
319
|
+
const branchMatch = entry.match(/^branch\s+refs\/heads\/(.+)/m);
|
|
320
|
+
if (branchMatch) worktreeBranches.add(branchMatch[1]);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
/* best effort */
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 3. List all local branches
|
|
328
|
+
let localBranches;
|
|
329
|
+
try {
|
|
330
|
+
const r = spawnSync(
|
|
331
|
+
"git",
|
|
332
|
+
["for-each-ref", "--format=%(refname:short)", "refs/heads/"],
|
|
333
|
+
{ cwd: repoRoot, encoding: "utf8", timeout: 10000, windowsHide: true },
|
|
334
|
+
);
|
|
335
|
+
if (r.status !== 0 || !r.stdout) return result;
|
|
336
|
+
localBranches = r.stdout.trim().split("\n").filter(Boolean);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
result.errors.push(`Failed to list branches: ${e.message}`);
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 4. Filter to target patterns only
|
|
343
|
+
const targetBranches = localBranches.filter((b) =>
|
|
344
|
+
patterns.some((p) => b.startsWith(p)),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
if (targetBranches.length === 0) return result;
|
|
348
|
+
|
|
349
|
+
const cutoff = Date.now() - minAgeMs;
|
|
350
|
+
|
|
351
|
+
for (const branch of targetBranches) {
|
|
352
|
+
// Skip protected branches
|
|
353
|
+
if (protectedBranches.includes(branch)) {
|
|
354
|
+
result.skipped.push({ branch, reason: "protected" });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Skip currently checked-out branch
|
|
359
|
+
if (branch === currentBranch) {
|
|
360
|
+
result.skipped.push({ branch, reason: "checked-out" });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Skip branches checked out in worktrees
|
|
365
|
+
if (worktreeBranches.has(branch)) {
|
|
366
|
+
result.skipped.push({ branch, reason: "active-worktree" });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Check last commit date
|
|
371
|
+
try {
|
|
372
|
+
const dateResult = spawnSync(
|
|
373
|
+
"git",
|
|
374
|
+
["log", "-1", "--format=%ct", branch],
|
|
375
|
+
{ cwd: repoRoot, encoding: "utf8", timeout: 5000, windowsHide: true },
|
|
376
|
+
);
|
|
377
|
+
if (dateResult.status !== 0 || !dateResult.stdout.trim()) {
|
|
378
|
+
result.skipped.push({ branch, reason: "no-commit-date" });
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const commitEpoch = parseInt(dateResult.stdout.trim(), 10) * 1000;
|
|
382
|
+
if (commitEpoch > cutoff) {
|
|
383
|
+
result.skipped.push({ branch, reason: "too-recent" });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
result.skipped.push({ branch, reason: "date-check-failed" });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Check if remote tracking branch exists and is in sync
|
|
392
|
+
const remoteRef = `origin/${branch}`;
|
|
393
|
+
const remoteExists = spawnSync(
|
|
394
|
+
"git",
|
|
395
|
+
["rev-parse", "--verify", `refs/remotes/${remoteRef}`],
|
|
396
|
+
{ cwd: repoRoot, timeout: 5000, windowsHide: true },
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
if (remoteExists.status === 0) {
|
|
400
|
+
// Remote exists — check if local is ahead (unpushed commits)
|
|
401
|
+
const aheadCheck = spawnSync(
|
|
402
|
+
"git",
|
|
403
|
+
["rev-list", "--count", `${remoteRef}..${branch}`],
|
|
404
|
+
{ cwd: repoRoot, encoding: "utf8", timeout: 5000, windowsHide: true },
|
|
405
|
+
);
|
|
406
|
+
const ahead = parseInt(aheadCheck.stdout?.trim(), 10) || 0;
|
|
407
|
+
if (ahead > 0) {
|
|
408
|
+
result.skipped.push({ branch, reason: "unpushed-commits" });
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
// No remote — check if merged into main (safe to delete if merged)
|
|
413
|
+
const mergedCheck = spawnSync(
|
|
414
|
+
"git",
|
|
415
|
+
["branch", "--merged", "main", "--list", branch],
|
|
416
|
+
{ cwd: repoRoot, encoding: "utf8", timeout: 5000, windowsHide: true },
|
|
417
|
+
);
|
|
418
|
+
const isMerged = mergedCheck.stdout?.trim() === branch;
|
|
419
|
+
if (!isMerged) {
|
|
420
|
+
result.skipped.push({ branch, reason: "not-pushed-not-merged" });
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// All checks passed — delete the branch
|
|
426
|
+
if (dryRun) {
|
|
427
|
+
console.log(`[maintenance] would delete stale branch: ${branch}`);
|
|
428
|
+
result.deleted.push(branch);
|
|
429
|
+
} else {
|
|
430
|
+
try {
|
|
431
|
+
const del = spawnSync("git", ["branch", "-D", branch], {
|
|
432
|
+
cwd: repoRoot,
|
|
433
|
+
encoding: "utf8",
|
|
434
|
+
timeout: 10000,
|
|
435
|
+
windowsHide: true,
|
|
436
|
+
});
|
|
437
|
+
if (del.status === 0) {
|
|
438
|
+
console.log(`[maintenance] deleted stale branch: ${branch}`);
|
|
439
|
+
result.deleted.push(branch);
|
|
440
|
+
} else {
|
|
441
|
+
const err = (del.stderr || del.stdout || "").trim();
|
|
442
|
+
result.errors.push(`${branch}: ${err}`);
|
|
443
|
+
}
|
|
444
|
+
} catch (e) {
|
|
445
|
+
result.errors.push(`${branch}: ${e.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (result.deleted.length > 0) {
|
|
451
|
+
console.log(
|
|
452
|
+
`[maintenance] branch cleanup: ${result.deleted.length} deleted, ${result.skipped.length} skipped, ${result.errors.length} errors`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Monitor Singleton via PID file ──────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
const PID_FILE_NAME = "openfleet.pid";
|
|
462
|
+
const MONITOR_MARKER = "openfleet/monitor.mjs";
|
|
463
|
+
|
|
464
|
+
function parsePidFile(raw) {
|
|
465
|
+
const text = String(raw || "").trim();
|
|
466
|
+
if (!text) return { pid: null, raw: text };
|
|
467
|
+
if (text.startsWith("{")) {
|
|
468
|
+
try {
|
|
469
|
+
const data = JSON.parse(text);
|
|
470
|
+
return { pid: Number(data?.pid), raw: text, data };
|
|
471
|
+
} catch {
|
|
472
|
+
return { pid: Number(text), raw: text };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return { pid: Number(text), raw: text };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function getProcessCommandLine(pid) {
|
|
479
|
+
if (!Number.isFinite(pid) || pid <= 0) return "";
|
|
480
|
+
const processes = getProcesses();
|
|
481
|
+
const entry = processes.find((p) => Number(p.pid) === Number(pid));
|
|
482
|
+
return entry?.commandLine || "";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function isMonitorProcess(pid) {
|
|
486
|
+
const cmd = getProcessCommandLine(pid);
|
|
487
|
+
if (!cmd) return false;
|
|
488
|
+
const normalized = cmd.toLowerCase();
|
|
489
|
+
if (normalized.includes(MONITOR_MARKER)) return true;
|
|
490
|
+
return normalized.includes("openfleet") && normalized.includes("monitor.mjs");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Acquire a singleton lock by writing our PID file.
|
|
495
|
+
* If a stale monitor is detected (PID file exists but process dead), clean up and take over.
|
|
496
|
+
* Returns true if we acquired the lock, false if another monitor is actually running.
|
|
497
|
+
*/
|
|
498
|
+
export function acquireMonitorLock(lockDir) {
|
|
499
|
+
const pidFile = resolve(lockDir, PID_FILE_NAME);
|
|
500
|
+
|
|
501
|
+
if (existsSync(pidFile)) {
|
|
502
|
+
try {
|
|
503
|
+
const raw = readFileSync(pidFile, "utf8");
|
|
504
|
+
const parsed = parsePidFile(raw);
|
|
505
|
+
const existingPid = parsed.pid;
|
|
506
|
+
if (
|
|
507
|
+
existingPid &&
|
|
508
|
+
existingPid !== process.pid &&
|
|
509
|
+
isProcessAlive(existingPid)
|
|
510
|
+
) {
|
|
511
|
+
if (isMonitorProcess(existingPid)) {
|
|
512
|
+
console.error(
|
|
513
|
+
`[maintenance] another openfleet is already running (PID ${existingPid}). Exiting.`,
|
|
514
|
+
);
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
console.warn(
|
|
518
|
+
`[maintenance] PID file points to non-monitor process (PID ${existingPid}); replacing lock`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
// Stale PID file — previous monitor crashed without cleanup
|
|
522
|
+
console.warn(
|
|
523
|
+
`[maintenance] removing stale PID file (PID ${parsed.raw || "unknown"} no longer alive)`,
|
|
524
|
+
);
|
|
525
|
+
} catch {
|
|
526
|
+
// Can't read PID file — just overwrite
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const payload = {
|
|
532
|
+
pid: process.pid,
|
|
533
|
+
started_at: new Date().toISOString(),
|
|
534
|
+
argv: process.argv,
|
|
535
|
+
};
|
|
536
|
+
writeFileSync(pidFile, JSON.stringify(payload, null, 2), "utf8");
|
|
537
|
+
// Clean up on exit
|
|
538
|
+
const cleanup = () => {
|
|
539
|
+
try {
|
|
540
|
+
const current = parsePidFile(readFileSync(pidFile, "utf8")).pid;
|
|
541
|
+
if (Number(current) === process.pid) {
|
|
542
|
+
unlinkSync(pidFile);
|
|
543
|
+
}
|
|
544
|
+
} catch {
|
|
545
|
+
/* best effort */
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
process.on("exit", cleanup);
|
|
549
|
+
process.on("SIGINT", () => {
|
|
550
|
+
cleanup();
|
|
551
|
+
process.exit(0);
|
|
552
|
+
});
|
|
553
|
+
process.on("SIGTERM", () => {
|
|
554
|
+
cleanup();
|
|
555
|
+
process.exit(0);
|
|
556
|
+
});
|
|
557
|
+
console.log(`[maintenance] monitor PID file written: ${pidFile}`);
|
|
558
|
+
return true;
|
|
559
|
+
} catch (e) {
|
|
560
|
+
console.warn(`[maintenance] failed to write PID file: ${e.message}`);
|
|
561
|
+
return true; // Don't block startup on PID file failure
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isProcessAlive(pid) {
|
|
566
|
+
try {
|
|
567
|
+
process.kill(pid, 0); // Signal 0 = check existence, don't actually kill
|
|
568
|
+
return true;
|
|
569
|
+
} catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── Full Maintenance Sweep ──────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Fast-forward local tracking branches (e.g. main) to match origin.
|
|
578
|
+
*
|
|
579
|
+
* When local `main` falls behind `origin/main`, new worktrees and task
|
|
580
|
+
* branches spawned from it start stale, causing avoidable rebase conflicts.
|
|
581
|
+
* This function periodically pulls so the local ref stays current.
|
|
582
|
+
*
|
|
583
|
+
* Safe: only does `--ff-only` — never creates merge commits. If the local
|
|
584
|
+
* branch has diverged (someone committed directly), it logs a warning and
|
|
585
|
+
* skips. Also skips if the branch is currently checked out with uncommitted
|
|
586
|
+
* work (git will refuse the checkout anyway).
|
|
587
|
+
*
|
|
588
|
+
* @param {string} repoRoot
|
|
589
|
+
* @param {string[]} [branches] - branches to sync (default: ["main"])
|
|
590
|
+
* @returns {number} count of branches successfully synced
|
|
591
|
+
*/
|
|
592
|
+
export function syncLocalTrackingBranches(repoRoot, branches) {
|
|
593
|
+
if (!repoRoot) return 0;
|
|
594
|
+
const toSync = branches && branches.length ? branches : ["main"];
|
|
595
|
+
let synced = 0;
|
|
596
|
+
|
|
597
|
+
// 1. Fetch all remotes first (single network call)
|
|
598
|
+
try {
|
|
599
|
+
spawnSync("git", ["fetch", "--all", "--prune", "--quiet"], {
|
|
600
|
+
cwd: repoRoot,
|
|
601
|
+
timeout: 60_000,
|
|
602
|
+
windowsHide: true,
|
|
603
|
+
});
|
|
604
|
+
} catch (e) {
|
|
605
|
+
console.warn(`[maintenance] git fetch --all failed: ${e.message}`);
|
|
606
|
+
return 0;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// 2. Determine which branch is currently checked out (so we can handle it)
|
|
610
|
+
let currentBranch = null;
|
|
611
|
+
try {
|
|
612
|
+
const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
613
|
+
cwd: repoRoot,
|
|
614
|
+
encoding: "utf8",
|
|
615
|
+
timeout: 5000,
|
|
616
|
+
windowsHide: true,
|
|
617
|
+
});
|
|
618
|
+
if (result.status === 0) currentBranch = result.stdout.trim();
|
|
619
|
+
} catch {
|
|
620
|
+
/* best effort */
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
for (const branch of toSync) {
|
|
624
|
+
try {
|
|
625
|
+
// Check if local branch exists
|
|
626
|
+
const refCheck = spawnSync(
|
|
627
|
+
"git",
|
|
628
|
+
["rev-parse", "--verify", `refs/heads/${branch}`],
|
|
629
|
+
{ cwd: repoRoot, timeout: 5000, windowsHide: true },
|
|
630
|
+
);
|
|
631
|
+
if (refCheck.status !== 0) {
|
|
632
|
+
// Local branch doesn't exist — nothing to sync
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Check if remote tracking ref exists
|
|
637
|
+
const remoteRef = `origin/${branch}`;
|
|
638
|
+
const remoteCheck = spawnSync(
|
|
639
|
+
"git",
|
|
640
|
+
["rev-parse", "--verify", `refs/remotes/${remoteRef}`],
|
|
641
|
+
{ cwd: repoRoot, timeout: 5000, windowsHide: true },
|
|
642
|
+
);
|
|
643
|
+
if (remoteCheck.status !== 0) continue;
|
|
644
|
+
|
|
645
|
+
// Compare: is local behind?
|
|
646
|
+
const behindCheck = spawnSync(
|
|
647
|
+
"git",
|
|
648
|
+
["rev-list", "--count", `${branch}..${remoteRef}`],
|
|
649
|
+
{ cwd: repoRoot, encoding: "utf8", timeout: 5000, windowsHide: true },
|
|
650
|
+
);
|
|
651
|
+
const behind = parseInt(behindCheck.stdout?.trim(), 10) || 0;
|
|
652
|
+
if (behind === 0) continue; // Already up to date
|
|
653
|
+
|
|
654
|
+
// Check if local has commits not in remote (diverged)
|
|
655
|
+
const aheadCheck = spawnSync(
|
|
656
|
+
"git",
|
|
657
|
+
["rev-list", "--count", `${remoteRef}..${branch}`],
|
|
658
|
+
{ cwd: repoRoot, encoding: "utf8", timeout: 5000, windowsHide: true },
|
|
659
|
+
);
|
|
660
|
+
const ahead = parseInt(aheadCheck.stdout?.trim(), 10) || 0;
|
|
661
|
+
if (ahead > 0) {
|
|
662
|
+
console.warn(
|
|
663
|
+
`[maintenance] local '${branch}' has ${ahead} commit(s) ahead of ${remoteRef} — skipping (diverged)`,
|
|
664
|
+
);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// If this is the currently checked-out branch, use git pull --ff-only
|
|
669
|
+
if (branch === currentBranch) {
|
|
670
|
+
// Check for uncommitted changes — skip if dirty
|
|
671
|
+
const statusCheck = spawnSync("git", ["status", "--porcelain"], {
|
|
672
|
+
cwd: repoRoot,
|
|
673
|
+
encoding: "utf8",
|
|
674
|
+
timeout: 5000,
|
|
675
|
+
windowsHide: true,
|
|
676
|
+
});
|
|
677
|
+
if (statusCheck.stdout?.trim()) {
|
|
678
|
+
console.warn(
|
|
679
|
+
`[maintenance] '${branch}' is checked out with uncommitted changes — skipping pull`,
|
|
680
|
+
);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const pull = spawnSync("git", ["pull", "--ff-only", "--quiet"], {
|
|
685
|
+
cwd: repoRoot,
|
|
686
|
+
encoding: "utf8",
|
|
687
|
+
timeout: 30_000,
|
|
688
|
+
windowsHide: true,
|
|
689
|
+
});
|
|
690
|
+
if (pull.status === 0) {
|
|
691
|
+
console.log(
|
|
692
|
+
`[maintenance] fast-forwarded checked-out '${branch}' (was ${behind} behind)`,
|
|
693
|
+
);
|
|
694
|
+
synced++;
|
|
695
|
+
} else {
|
|
696
|
+
console.warn(
|
|
697
|
+
`[maintenance] git pull --ff-only on '${branch}' failed: ${(pull.stderr || pull.stdout || "").toString().trim()}`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
// Not checked out — use git fetch to update the local ref directly
|
|
702
|
+
// This is safe because no worktree has it checked out
|
|
703
|
+
const update = spawnSync(
|
|
704
|
+
"git",
|
|
705
|
+
["update-ref", `refs/heads/${branch}`, `refs/remotes/${remoteRef}`],
|
|
706
|
+
{ cwd: repoRoot, timeout: 5000, windowsHide: true },
|
|
707
|
+
);
|
|
708
|
+
if (update.status === 0) {
|
|
709
|
+
console.log(
|
|
710
|
+
`[maintenance] fast-forwarded '${branch}' → ${remoteRef} (was ${behind} behind)`,
|
|
711
|
+
);
|
|
712
|
+
synced++;
|
|
713
|
+
} else {
|
|
714
|
+
console.warn(`[maintenance] update-ref failed for '${branch}'`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
} catch (e) {
|
|
718
|
+
console.warn(`[maintenance] error syncing '${branch}': ${e.message}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (synced > 0) {
|
|
723
|
+
console.log(
|
|
724
|
+
`[maintenance] synced ${synced}/${toSync.length} local tracking branch(es)`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
return synced;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Run full maintenance sweep: stale kill, git push reap, worktree cleanup,
|
|
732
|
+
* local tracking branch sync, and optionally VK task archiving.
|
|
733
|
+
* @param {object} opts
|
|
734
|
+
* @param {string} opts.repoRoot - repository root path
|
|
735
|
+
* @param {number} [opts.childPid] - current orchestrator child PID to skip
|
|
736
|
+
* @param {number} [opts.gitPushMaxAgeMs] - max age for git push before kill (default 5min)
|
|
737
|
+
* @param {string[]} [opts.syncBranches] - local branches to fast-forward (default: ["main"])
|
|
738
|
+
* @param {function} [opts.archiveCompletedTasks] - optional async function to archive VK tasks
|
|
739
|
+
* @param {object} [opts.branchCleanup] - branch cleanup options (passed to cleanupStaleBranches)
|
|
740
|
+
* @param {boolean} [opts.branchCleanup.enabled=true] - enable/disable branch cleanup
|
|
741
|
+
* @param {number} [opts.branchCleanup.minAgeMs] - minimum branch age before cleanup (default 24h)
|
|
742
|
+
* @param {boolean} [opts.branchCleanup.dryRun] - if true, log only without deleting
|
|
743
|
+
*/
|
|
744
|
+
export async function runMaintenanceSweep(opts = {}) {
|
|
745
|
+
const {
|
|
746
|
+
repoRoot,
|
|
747
|
+
childPid,
|
|
748
|
+
gitPushMaxAgeMs,
|
|
749
|
+
syncBranches,
|
|
750
|
+
archiveCompletedTasks,
|
|
751
|
+
branchCleanup,
|
|
752
|
+
} = opts;
|
|
753
|
+
console.log("[maintenance] starting sweep...");
|
|
754
|
+
|
|
755
|
+
const staleKilled = killStaleOrchestrators(childPid);
|
|
756
|
+
const pushesReaped = reapStuckGitPushes(gitPushMaxAgeMs);
|
|
757
|
+
const worktreesPruned = repoRoot ? cleanupWorktrees(repoRoot) : 0;
|
|
758
|
+
|
|
759
|
+
// Also prune via centralized WorktreeManager
|
|
760
|
+
try {
|
|
761
|
+
const pruneResult = await pruneStaleWorktrees();
|
|
762
|
+
if (pruneResult.pruned > 0) {
|
|
763
|
+
console.log(
|
|
764
|
+
`[maintenance] WorktreeManager pruned ${pruneResult.pruned} stale worktrees`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
} catch (wtErr) {
|
|
768
|
+
console.warn(
|
|
769
|
+
`[maintenance] WorktreeManager prune failed: ${wtErr.message}`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const branchesSynced = repoRoot
|
|
774
|
+
? syncLocalTrackingBranches(repoRoot, syncBranches)
|
|
775
|
+
: 0;
|
|
776
|
+
|
|
777
|
+
// Branch cleanup: delete old ve/* and copilot-worktree-* branches
|
|
778
|
+
let branchesDeleted = 0;
|
|
779
|
+
const branchCleanupEnabled = branchCleanup?.enabled !== false;
|
|
780
|
+
if (repoRoot && branchCleanupEnabled) {
|
|
781
|
+
try {
|
|
782
|
+
const branchResult = cleanupStaleBranches(repoRoot, {
|
|
783
|
+
minAgeMs: branchCleanup?.minAgeMs,
|
|
784
|
+
dryRun: branchCleanup?.dryRun,
|
|
785
|
+
});
|
|
786
|
+
branchesDeleted = branchResult.deleted.length;
|
|
787
|
+
} catch (err) {
|
|
788
|
+
console.warn(`[maintenance] branch cleanup failed: ${err.message}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Optional: Archive old completed VK tasks (if provided)
|
|
793
|
+
let tasksArchived = 0;
|
|
794
|
+
if (archiveCompletedTasks && typeof archiveCompletedTasks === "function") {
|
|
795
|
+
try {
|
|
796
|
+
const result = await archiveCompletedTasks();
|
|
797
|
+
tasksArchived = result?.archived || 0;
|
|
798
|
+
if (tasksArchived > 0) {
|
|
799
|
+
console.log(
|
|
800
|
+
`[maintenance] archived ${tasksArchived} old completed tasks`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
} catch (err) {
|
|
804
|
+
console.warn(`[maintenance] task archiving failed: ${err.message}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Guard against core.bare=true corruption that accumulates from worktree ops
|
|
809
|
+
try {
|
|
810
|
+
const repoRoot = resolve(import.meta.dirname || ".", "..", "..");
|
|
811
|
+
fixGitConfigCorruption(repoRoot);
|
|
812
|
+
} catch {
|
|
813
|
+
/* best-effort */
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
console.log(
|
|
817
|
+
`[maintenance] sweep complete: ${staleKilled} stale orchestrators, ${pushesReaped} stuck pushes, ${worktreesPruned} worktrees pruned, ${branchesSynced} branches synced, ${branchesDeleted} stale branches deleted`,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
staleKilled,
|
|
822
|
+
pushesReaped,
|
|
823
|
+
worktreesPruned,
|
|
824
|
+
branchesSynced,
|
|
825
|
+
tasksArchived,
|
|
826
|
+
branchesDeleted,
|
|
827
|
+
};
|
|
828
|
+
}
|