@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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -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
+ }