@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
|
@@ -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 };
|