@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,581 @@
1
+ /**
2
+ * @fileoverview Real-time workspace monitoring and log streaming
3
+ * Tracks VK workspace sessions, streams logs, detects stuck agents, caches state
4
+ */
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
8
+ import { resolve, dirname } from "node:path";
9
+ import { spawn } from "node:child_process";
10
+
11
+ const MONITOR_INTERVAL_MS = 30_000; // Check every 30 seconds
12
+ const STUCK_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes without progress
13
+ const REBASE_COMMIT_THRESHOLD = 20; // If rebasing >20 commits, consider merge instead
14
+ const MAX_DUPLICATE_COMMITS = 5; // Flag if >5 commits with same message
15
+ const ERROR_LOOP_THRESHOLD = 3; // Same error 3 times = loop
16
+ const ERROR_LOOP_WINDOW_MS = 15 * 60 * 1000; // Within 15 minutes
17
+
18
+ // ── Workspace State Cache ─────────────────────────────────────────────────────
19
+
20
+ class WorkspaceMonitor {
21
+ constructor(options = {}) {
22
+ this.cacheDir = options.cacheDir || resolve(".cache", "workspace-logs");
23
+ this.repoRoot = options.repoRoot || process.cwd();
24
+ this.workspaces = new Map(); // attemptId -> WorkspaceState
25
+ this.monitorInterval = null;
26
+ this.onStuckDetected = options.onStuckDetected || null;
27
+ this.onProgressUpdate = options.onProgressUpdate || null;
28
+ }
29
+
30
+ async init() {
31
+ await mkdir(this.cacheDir, { recursive: true });
32
+ console.log(`[workspace-monitor] initialized (cache: ${this.cacheDir})`);
33
+ }
34
+
35
+ /**
36
+ * Start monitoring a workspace session
37
+ */
38
+ async startMonitoring(attemptId, workspacePath, metadata = {}) {
39
+ if (this.workspaces.has(attemptId)) {
40
+ console.warn(
41
+ `[workspace-monitor] already monitoring ${attemptId}, skipping`,
42
+ );
43
+ return;
44
+ }
45
+
46
+ const state = {
47
+ attemptId,
48
+ workspacePath,
49
+ taskId: metadata.taskId,
50
+ executor: metadata.executor,
51
+ startedAt: Date.now(),
52
+ lastProgressAt: Date.now(),
53
+ lastGitCheck: null,
54
+ gitState: null,
55
+ commitHistory: [],
56
+ fileChanges: [],
57
+ errorHistory: [], // Track errors for loop detection
58
+ rebaseAttempts: 0, // Count rebase attempts
59
+ conflictCount: 0, // Count conflicts encountered
60
+ lastError: null, // Last error fingerprint
61
+ lastErrorAt: null, // When last error occurred
62
+ logFilePath: resolve(this.cacheDir, `${attemptId}.log`),
63
+ stateFilePath: resolve(this.cacheDir, `${attemptId}.state.json`),
64
+ stuck: false,
65
+ stuckReason: null,
66
+ errorLoop: false, // Detected error loop
67
+ errorLoopType: null, // Type of loop (rebase, conflict, command)
68
+ };
69
+
70
+ this.workspaces.set(attemptId, state);
71
+
72
+ // Create log file
73
+ await writeFile(
74
+ state.logFilePath,
75
+ `[${new Date().toISOString()}] Monitoring started for attempt ${attemptId}\n` +
76
+ `Workspace: ${workspacePath}\n` +
77
+ `Task: ${metadata.taskId}\n` +
78
+ `Executor: ${metadata.executor}\n` +
79
+ `---\n\n`,
80
+ );
81
+
82
+ console.log(
83
+ `[workspace-monitor] started monitoring ${attemptId} (${workspacePath})`,
84
+ );
85
+
86
+ // Start periodic checks if not already running
87
+ if (!this.monitorInterval) {
88
+ this.monitorInterval = setInterval(
89
+ () => this.checkAllWorkspaces(),
90
+ MONITOR_INTERVAL_MS,
91
+ );
92
+ }
93
+
94
+ // Do initial check
95
+ await this.checkWorkspace(attemptId);
96
+ }
97
+
98
+ /**
99
+ * Stop monitoring a workspace
100
+ */
101
+ async stopMonitoring(attemptId, reason = "completed") {
102
+ const state = this.workspaces.get(attemptId);
103
+ if (!state) return;
104
+
105
+ await this.logWorkspace(
106
+ attemptId,
107
+ `\n[${new Date().toISOString()}] Monitoring stopped: ${reason}\n`,
108
+ );
109
+
110
+ // Save final state
111
+ await this.saveWorkspaceState(attemptId);
112
+
113
+ this.workspaces.delete(attemptId);
114
+
115
+ // Stop interval if no more workspaces
116
+ if (this.workspaces.size === 0 && this.monitorInterval) {
117
+ clearInterval(this.monitorInterval);
118
+ this.monitorInterval = null;
119
+ }
120
+
121
+ console.log(`[workspace-monitor] stopped monitoring ${attemptId}`);
122
+ }
123
+
124
+ /**
125
+ * Check all monitored workspaces for issues
126
+ */
127
+ async checkAllWorkspaces() {
128
+ const promises = [];
129
+ for (const attemptId of this.workspaces.keys()) {
130
+ promises.push(
131
+ this.checkWorkspace(attemptId).catch((err) => {
132
+ console.error(
133
+ `[workspace-monitor] error checking ${attemptId}: ${err.message}`,
134
+ );
135
+ }),
136
+ );
137
+ }
138
+ await Promise.all(promises);
139
+ }
140
+
141
+ /**
142
+ * Check a single workspace for stuck conditions
143
+ */
144
+ async checkWorkspace(attemptId) {
145
+ const state = this.workspaces.get(attemptId);
146
+ if (!state) return;
147
+
148
+ const now = Date.now();
149
+ const gitState = await this.captureGitState(state.workspacePath);
150
+
151
+ if (!gitState) {
152
+ await this.logWorkspace(
153
+ attemptId,
154
+ `[${new Date().toISOString()}] ERROR: Could not read git state\n`,
155
+ );
156
+ return;
157
+ }
158
+
159
+ // Update state
160
+ state.lastGitCheck = now;
161
+ const oldGitState = state.gitState;
162
+ state.gitState = gitState;
163
+
164
+ // Detect progress
165
+ const hasProgress = this.detectProgress(oldGitState, gitState);
166
+ if (hasProgress) {
167
+ state.lastProgressAt = now;
168
+ state.stuck = false;
169
+ state.stuckReason = null;
170
+ }
171
+
172
+ // Log git state changes
173
+ if (oldGitState && this.hasGitStateChanged(oldGitState, gitState)) {
174
+ await this.logWorkspace(
175
+ attemptId,
176
+ `[${new Date().toISOString()}] Git state update:\n` +
177
+ ` Branch: ${gitState.branch}\n` +
178
+ ` Commits ahead: ${gitState.commitsAhead}\n` +
179
+ ` Commits behind: ${gitState.commitsBehind}\n` +
180
+ ` Modified files: ${gitState.modifiedFiles}\n` +
181
+ ` Rebase in progress: ${gitState.rebaseInProgress}\n` +
182
+ (gitState.rebaseInProgress
183
+ ? ` Rebase done/todo: ${gitState.rebaseDone}/${gitState.rebaseTodo}\n`
184
+ : ""),
185
+ );
186
+ }
187
+
188
+ // Detect stuck conditions
189
+ const stuckCheck = this.detectStuck(state, now);
190
+ if (stuckCheck.stuck && !state.stuck) {
191
+ state.stuck = true;
192
+ state.stuckReason = stuckCheck.reason;
193
+
194
+ await this.logWorkspace(
195
+ attemptId,
196
+ `[${new Date().toISOString()}] ⚠️ STUCK DETECTED: ${stuckCheck.reason}\n` +
197
+ ` Time since last progress: ${Math.round((now - state.lastProgressAt) / 60000)} minutes\n` +
198
+ ` Recommendation: ${stuckCheck.recommendation}\n\n`,
199
+ );
200
+
201
+ // Trigger callback
202
+ if (this.onStuckDetected) {
203
+ this.onStuckDetected({
204
+ attemptId,
205
+ reason: stuckCheck.reason,
206
+ recommendation: stuckCheck.recommendation,
207
+ state,
208
+ });
209
+ }
210
+ }
211
+
212
+ // Check for inefficient patterns
213
+ const warnings = this.detectInefficiencies(gitState, state);
214
+ for (const warning of warnings) {
215
+ await this.logWorkspace(
216
+ attemptId,
217
+ `[${new Date().toISOString()}] ⚠️ ${warning}\n`,
218
+ );
219
+ }
220
+
221
+ // Save state snapshot
222
+ await this.saveWorkspaceState(attemptId);
223
+
224
+ // Trigger progress callback
225
+ if (this.onProgressUpdate && hasProgress) {
226
+ this.onProgressUpdate({ attemptId, gitState, state });
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Capture current git state from workspace
232
+ */
233
+ async captureGitState(workspacePath) {
234
+ if (!existsSync(workspacePath)) {
235
+ return null;
236
+ }
237
+
238
+ const gitDir = resolve(workspacePath, ".git");
239
+ if (!existsSync(gitDir)) {
240
+ return null;
241
+ }
242
+
243
+ try {
244
+ // Get branch
245
+ const branch = await this.gitCommand(workspacePath, [
246
+ "rev-parse",
247
+ "--abbrev-ref",
248
+ "HEAD",
249
+ ]);
250
+
251
+ // Check if rebase in progress
252
+ const rebaseMergeDir = resolve(gitDir, "rebase-merge");
253
+ const rebaseInProgress = existsSync(rebaseMergeDir);
254
+
255
+ let rebaseDone = 0;
256
+ let rebaseTodo = 0;
257
+ if (rebaseInProgress) {
258
+ try {
259
+ const doneFile = resolve(rebaseMergeDir, "done");
260
+ const todoFile = resolve(rebaseMergeDir, "git-rebase-todo");
261
+ if (existsSync(doneFile)) {
262
+ const done = await readFile(doneFile, "utf8");
263
+ rebaseDone = done.split("\n").filter((l) => l.trim()).length;
264
+ }
265
+ if (existsSync(todoFile)) {
266
+ const todo = await readFile(todoFile, "utf8");
267
+ rebaseTodo = todo
268
+ .split("\n")
269
+ .filter((l) => l.trim() && l.startsWith("pick ")).length;
270
+ }
271
+ } catch {
272
+ /* best effort */
273
+ }
274
+ }
275
+
276
+ // Get commits ahead/behind
277
+ const [ahead, behind, modified, untracked, lastCommit] =
278
+ await Promise.all([
279
+ this.gitCommand(workspacePath, [
280
+ "rev-list",
281
+ "--count",
282
+ "@{u}..HEAD",
283
+ ]).catch(() => "0"),
284
+ this.gitCommand(workspacePath, [
285
+ "rev-list",
286
+ "--count",
287
+ "HEAD..@{u}",
288
+ ]).catch(() => "0"),
289
+ this.gitCommand(workspacePath, ["diff", "--name-only"]).then(
290
+ (out) => out.split("\n").filter(Boolean).length,
291
+ ),
292
+ this.gitCommand(workspacePath, [
293
+ "ls-files",
294
+ "--others",
295
+ "--exclude-standard",
296
+ ]).then((out) => out.split("\n").filter(Boolean).length),
297
+ this.gitCommand(workspacePath, [
298
+ "log",
299
+ "-1",
300
+ "--format=%H|%s|%ct",
301
+ ]).catch(() => "||0"),
302
+ ]);
303
+
304
+ const [lastHash, lastMessage, lastTimestamp] = lastCommit.split("|");
305
+
306
+ return {
307
+ branch: branch.trim(),
308
+ rebaseInProgress,
309
+ rebaseDone,
310
+ rebaseTodo,
311
+ commitsAhead: parseInt(ahead, 10) || 0,
312
+ commitsBehind: parseInt(behind, 10) || 0,
313
+ modifiedFiles: modified,
314
+ untrackedFiles: untracked,
315
+ lastCommitHash: lastHash,
316
+ lastCommitMessage: lastMessage,
317
+ lastCommitTime: parseInt(lastTimestamp, 10) * 1000,
318
+ };
319
+ } catch (err) {
320
+ console.error(
321
+ `[workspace-monitor] error capturing git state: ${err.message}`,
322
+ );
323
+ return null;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Execute git command in workspace
329
+ */
330
+ async gitCommand(cwd, args) {
331
+ // Basic safety check: prevent use of dangerous git options that can lead to
332
+ // command execution (e.g., via --upload-pack on certain subcommands).
333
+ if (!Array.isArray(args)) {
334
+ throw new TypeError("gitCommand expected args to be an array");
335
+ }
336
+
337
+ for (const arg of args) {
338
+ // Disallow --upload-pack and its variants (e.g., --upload-pack=/path/to/cmd)
339
+ if (typeof arg === "string" && (arg === "--upload-pack" || arg.startsWith("--upload-pack="))) {
340
+ throw new Error("Usage of --upload-pack is not allowed in gitCommand");
341
+ }
342
+ }
343
+
344
+ return new Promise((resolve, reject) => {
345
+ const proc = spawn("git", args, { cwd, shell: false });
346
+ let stdout = "";
347
+ let stderr = "";
348
+
349
+ proc.stdout.on("data", (chunk) => {
350
+ stdout += chunk.toString();
351
+ });
352
+ proc.stderr.on("data", (chunk) => {
353
+ stderr += chunk.toString();
354
+ });
355
+
356
+ proc.on("error", reject);
357
+ proc.on("close", (code) => {
358
+ if (code === 0) {
359
+ resolve(stdout.trim());
360
+ } else {
361
+ reject(new Error(stderr || `git exited with code ${code}`));
362
+ }
363
+ });
364
+ });
365
+ }
366
+
367
+ /**
368
+ * Detect if progress was made since last check
369
+ */
370
+ detectProgress(oldState, newState) {
371
+ if (!oldState) return true; // First check counts as progress
372
+
373
+ // Different commit hash = progress
374
+ if (oldState.lastCommitHash !== newState.lastCommitHash) return true;
375
+
376
+ // File changes = progress
377
+ if (
378
+ oldState.modifiedFiles !== newState.modifiedFiles ||
379
+ oldState.untrackedFiles !== newState.untrackedFiles
380
+ )
381
+ return true;
382
+
383
+ // Rebase progress
384
+ if (
385
+ newState.rebaseInProgress &&
386
+ oldState.rebaseDone !== newState.rebaseDone
387
+ )
388
+ return true;
389
+
390
+ return false;
391
+ }
392
+
393
+ /**
394
+ * Check if git state changed meaningfully
395
+ */
396
+ hasGitStateChanged(oldState, newState) {
397
+ return (
398
+ oldState.branch !== newState.branch ||
399
+ oldState.lastCommitHash !== newState.lastCommitHash ||
400
+ oldState.rebaseInProgress !== newState.rebaseInProgress ||
401
+ oldState.rebaseDone !== newState.rebaseDone ||
402
+ oldState.commitsAhead !== newState.commitsAhead ||
403
+ oldState.commitsBehind !== newState.commitsBehind
404
+ );
405
+ }
406
+
407
+ /**
408
+ * Detect stuck conditions
409
+ */
410
+ detectStuck(state, now) {
411
+ const timeSinceProgress = now - state.lastProgressAt;
412
+
413
+ // Stuck in rebase for >10 minutes
414
+ if (
415
+ state.gitState?.rebaseInProgress &&
416
+ timeSinceProgress > STUCK_THRESHOLD_MS
417
+ ) {
418
+ const totalCommits =
419
+ state.gitState.rebaseDone + state.gitState.rebaseTodo;
420
+ return {
421
+ stuck: true,
422
+ reason: `Stuck in rebase (${state.gitState.rebaseDone}/${totalCommits} commits, ${Math.round(timeSinceProgress / 60000)}min)`,
423
+ recommendation:
424
+ totalCommits > REBASE_COMMIT_THRESHOLD
425
+ ? "Abort rebase, use merge instead for large drifts"
426
+ : "Check for conflicts or infinite loops",
427
+ };
428
+ }
429
+
430
+ // No progress for >10 minutes
431
+ if (timeSinceProgress > STUCK_THRESHOLD_MS) {
432
+ return {
433
+ stuck: true,
434
+ reason: `No progress for ${Math.round(timeSinceProgress / 60000)} minutes`,
435
+ recommendation: "Agent may be stuck in a loop or waiting for input",
436
+ };
437
+ }
438
+
439
+ return { stuck: false };
440
+ }
441
+
442
+ /**
443
+ * Detect inefficient patterns
444
+ */
445
+ detectInefficiencies(gitState, state) {
446
+ const warnings = [];
447
+
448
+ // Massive rebase
449
+ if (gitState.rebaseInProgress) {
450
+ const totalCommits = gitState.rebaseDone + gitState.rebaseTodo;
451
+ if (totalCommits > REBASE_COMMIT_THRESHOLD) {
452
+ warnings.push(
453
+ `INEFFICIENCY: Rebasing ${totalCommits} commits (consider merge for >20 commits)`,
454
+ );
455
+ }
456
+ }
457
+
458
+ // Too many commits ahead (agent making micro-commits)
459
+ if (gitState.commitsAhead > 50) {
460
+ warnings.push(
461
+ `INEFFICIENCY: ${gitState.commitsAhead} commits ahead (agent should squash commits)`,
462
+ );
463
+ }
464
+
465
+ // Check for duplicate commit messages
466
+ if (gitState.lastCommitMessage && state.commitHistory.length > 0) {
467
+ const duplicates = state.commitHistory.filter(
468
+ (c) => c.message === gitState.lastCommitMessage,
469
+ ).length;
470
+ if (duplicates > MAX_DUPLICATE_COMMITS) {
471
+ warnings.push(
472
+ `INEFFICIENCY: Duplicate commit message "${gitState.lastCommitMessage}" (${duplicates} times)`,
473
+ );
474
+ }
475
+ }
476
+
477
+ // Update commit history
478
+ if (
479
+ !state.commitHistory.find((c) => c.hash === gitState.lastCommitHash)
480
+ ) {
481
+ state.commitHistory.push({
482
+ hash: gitState.lastCommitHash,
483
+ message: gitState.lastCommitMessage,
484
+ timestamp: gitState.lastCommitTime,
485
+ });
486
+
487
+ // Keep only last 100 commits in memory
488
+ if (state.commitHistory.length > 100) {
489
+ state.commitHistory = state.commitHistory.slice(-100);
490
+ }
491
+ }
492
+
493
+ return warnings;
494
+ }
495
+
496
+ /**
497
+ * Append to workspace log file
498
+ */
499
+ async logWorkspace(attemptId, message) {
500
+ const state = this.workspaces.get(attemptId);
501
+ if (!state) return;
502
+
503
+ try {
504
+ await appendFile(state.logFilePath, message);
505
+ } catch (err) {
506
+ console.error(
507
+ `[workspace-monitor] failed to write log for ${attemptId}: ${err.message}`,
508
+ );
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Save workspace state to JSON
514
+ */
515
+ async saveWorkspaceState(attemptId) {
516
+ const state = this.workspaces.get(attemptId);
517
+ if (!state) return;
518
+
519
+ try {
520
+ const snapshot = {
521
+ attemptId: state.attemptId,
522
+ taskId: state.taskId,
523
+ executor: state.executor,
524
+ workspacePath: state.workspacePath,
525
+ startedAt: state.startedAt,
526
+ lastProgressAt: state.lastProgressAt,
527
+ lastGitCheck: state.lastGitCheck,
528
+ gitState: state.gitState,
529
+ commitHistory: state.commitHistory,
530
+ stuck: state.stuck,
531
+ stuckReason: state.stuckReason,
532
+ logFile: state.logFilePath,
533
+ };
534
+
535
+ await writeFile(
536
+ state.stateFilePath,
537
+ JSON.stringify(snapshot, null, 2),
538
+ "utf8",
539
+ );
540
+ } catch (err) {
541
+ console.error(
542
+ `[workspace-monitor] failed to save state for ${attemptId}: ${err.message}`,
543
+ );
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Get current state for an attempt
549
+ */
550
+ getState(attemptId) {
551
+ return this.workspaces.get(attemptId);
552
+ }
553
+
554
+ /**
555
+ * Get all monitored workspaces
556
+ */
557
+ getAllStates() {
558
+ return Array.from(this.workspaces.values());
559
+ }
560
+
561
+ /**
562
+ * Cleanup and stop monitoring
563
+ */
564
+ async shutdown() {
565
+ if (this.monitorInterval) {
566
+ clearInterval(this.monitorInterval);
567
+ this.monitorInterval = null;
568
+ }
569
+
570
+ // Save all states before shutdown
571
+ const promises = [];
572
+ for (const attemptId of this.workspaces.keys()) {
573
+ promises.push(this.stopMonitoring(attemptId, "shutdown"));
574
+ }
575
+ await Promise.all(promises);
576
+
577
+ console.log("[workspace-monitor] shut down");
578
+ }
579
+ }
580
+
581
+ export { WorkspaceMonitor };