@agent-relay/wrapper 0.1.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/dist/__fixtures__/claude-outputs.d.ts +49 -0
- package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/claude-outputs.js +443 -0
- package/dist/__fixtures__/claude-outputs.js.map +1 -0
- package/dist/__fixtures__/codex-outputs.d.ts +9 -0
- package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/codex-outputs.js +94 -0
- package/dist/__fixtures__/codex-outputs.js.map +1 -0
- package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
- package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/gemini-outputs.js +144 -0
- package/dist/__fixtures__/gemini-outputs.js.map +1 -0
- package/dist/__fixtures__/index.d.ts +68 -0
- package/dist/__fixtures__/index.d.ts.map +1 -0
- package/dist/__fixtures__/index.js +44 -0
- package/dist/__fixtures__/index.js.map +1 -0
- package/dist/auth-detection.d.ts +49 -0
- package/dist/auth-detection.d.ts.map +1 -0
- package/dist/auth-detection.js +199 -0
- package/dist/auth-detection.js.map +1 -0
- package/dist/base-wrapper.d.ts +225 -0
- package/dist/base-wrapper.d.ts.map +1 -0
- package/dist/base-wrapper.js +572 -0
- package/dist/base-wrapper.js.map +1 -0
- package/dist/client.d.ts +254 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +801 -0
- package/dist/client.js.map +1 -0
- package/dist/id-generator.d.ts +35 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +60 -0
- package/dist/id-generator.js.map +1 -0
- package/dist/idle-detector.d.ts +110 -0
- package/dist/idle-detector.d.ts.map +1 -0
- package/dist/idle-detector.js +304 -0
- package/dist/idle-detector.js.map +1 -0
- package/dist/inbox.d.ts +37 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +73 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +236 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +1238 -0
- package/dist/parser.js.map +1 -0
- package/dist/prompt-composer.d.ts +67 -0
- package/dist/prompt-composer.d.ts.map +1 -0
- package/dist/prompt-composer.js +168 -0
- package/dist/prompt-composer.js.map +1 -0
- package/dist/relay-pty-orchestrator.d.ts +407 -0
- package/dist/relay-pty-orchestrator.d.ts.map +1 -0
- package/dist/relay-pty-orchestrator.js +1885 -0
- package/dist/relay-pty-orchestrator.js.map +1 -0
- package/dist/shared.d.ts +201 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +341 -0
- package/dist/shared.js.map +1 -0
- package/dist/stuck-detector.d.ts +161 -0
- package/dist/stuck-detector.d.ts.map +1 -0
- package/dist/stuck-detector.js +402 -0
- package/dist/stuck-detector.js.map +1 -0
- package/dist/tmux-resolver.d.ts +55 -0
- package/dist/tmux-resolver.d.ts.map +1 -0
- package/dist/tmux-resolver.js +175 -0
- package/dist/tmux-resolver.js.map +1 -0
- package/dist/tmux-wrapper.d.ts +345 -0
- package/dist/tmux-wrapper.d.ts.map +1 -0
- package/dist/tmux-wrapper.js +1747 -0
- package/dist/tmux-wrapper.js.map +1 -0
- package/dist/trajectory-integration.d.ts +292 -0
- package/dist/trajectory-integration.d.ts.map +1 -0
- package/dist/trajectory-integration.js +979 -0
- package/dist/trajectory-integration.js.map +1 -0
- package/dist/wrapper-types.d.ts +41 -0
- package/dist/wrapper-types.d.ts.map +1 -0
- package/dist/wrapper-types.js +7 -0
- package/dist/wrapper-types.js.map +1 -0
- package/package.json +63 -0
- package/src/__fixtures__/claude-outputs.ts +471 -0
- package/src/__fixtures__/codex-outputs.ts +99 -0
- package/src/__fixtures__/gemini-outputs.ts +151 -0
- package/src/__fixtures__/index.ts +47 -0
- package/src/auth-detection.ts +244 -0
- package/src/base-wrapper.test.ts +540 -0
- package/src/base-wrapper.ts +741 -0
- package/src/client.test.ts +262 -0
- package/src/client.ts +984 -0
- package/src/id-generator.test.ts +71 -0
- package/src/id-generator.ts +69 -0
- package/src/idle-detector.test.ts +390 -0
- package/src/idle-detector.ts +370 -0
- package/src/inbox.test.ts +233 -0
- package/src/inbox.ts +89 -0
- package/src/index.ts +170 -0
- package/src/parser.regression.test.ts +251 -0
- package/src/parser.test.ts +1359 -0
- package/src/parser.ts +1477 -0
- package/src/prompt-composer.test.ts +219 -0
- package/src/prompt-composer.ts +231 -0
- package/src/relay-pty-orchestrator.test.ts +1027 -0
- package/src/relay-pty-orchestrator.ts +2270 -0
- package/src/shared.test.ts +221 -0
- package/src/shared.ts +454 -0
- package/src/stuck-detector.test.ts +303 -0
- package/src/stuck-detector.ts +511 -0
- package/src/tmux-resolver.test.ts +104 -0
- package/src/tmux-resolver.ts +207 -0
- package/src/tmux-wrapper.test.ts +316 -0
- package/src/tmux-wrapper.ts +2010 -0
- package/src/trajectory-detection.test.ts +151 -0
- package/src/trajectory-integration.ts +1261 -0
- package/src/wrapper-types.ts +45 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StuckDetector - Detect when an agent is stuck
|
|
3
|
+
*
|
|
4
|
+
* Implements agent-relay-501: Stuck detection heuristics
|
|
5
|
+
*
|
|
6
|
+
* Detects five stuck conditions:
|
|
7
|
+
* 1. Extended idle (no output for 10+ minutes)
|
|
8
|
+
* 2. Error loop (same error message repeated 3+ times)
|
|
9
|
+
* 3. Output loop (same output pattern repeated 3+ times)
|
|
10
|
+
* 4. Tool loop (same file operated on 10+ times in 5 minutes)
|
|
11
|
+
* 5. Output flood (abnormally high output rate suggesting infinite loop)
|
|
12
|
+
*
|
|
13
|
+
* NOTE: Message intent detection (agent says "I'll send" but doesn't) was removed
|
|
14
|
+
* because pattern-based NLP detection is unreliable. A protocol-level approach
|
|
15
|
+
* (detecting stale outbox files) should be implemented in relay-pty instead.
|
|
16
|
+
*
|
|
17
|
+
* Emits 'stuck' event when detected, with reason and details.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { EventEmitter } from 'node:events';
|
|
21
|
+
|
|
22
|
+
export type StuckReason = 'extended_idle' | 'error_loop' | 'output_loop' | 'tool_loop' | 'output_flood';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Tracked tool invocation for loop detection
|
|
26
|
+
*/
|
|
27
|
+
interface ToolInvocation {
|
|
28
|
+
tool: string; // 'Read', 'Write', 'Edit', 'Bash', etc.
|
|
29
|
+
target: string; // File path or command
|
|
30
|
+
timestamp: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StuckEvent {
|
|
34
|
+
reason: StuckReason;
|
|
35
|
+
details: string;
|
|
36
|
+
timestamp: number;
|
|
37
|
+
/** Time since last output in ms (for extended_idle) */
|
|
38
|
+
idleDurationMs?: number;
|
|
39
|
+
/** Repeated content (for loops) */
|
|
40
|
+
repeatedContent?: string;
|
|
41
|
+
/** Number of repetitions (for loops) */
|
|
42
|
+
repetitions?: number;
|
|
43
|
+
/** Target file/command (for tool_loop) */
|
|
44
|
+
targetFile?: string;
|
|
45
|
+
/** Tool name (for tool_loop) */
|
|
46
|
+
toolName?: string;
|
|
47
|
+
/** Output rate in lines per minute (for output_flood) */
|
|
48
|
+
linesPerMinute?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StuckDetectorConfig {
|
|
52
|
+
/** Duration of inactivity before considered stuck (ms, default: 10 minutes) */
|
|
53
|
+
extendedIdleMs?: number;
|
|
54
|
+
/** Number of repeated outputs before considered stuck (default: 3) */
|
|
55
|
+
loopThreshold?: number;
|
|
56
|
+
/** Check interval (ms, default: 30 seconds) */
|
|
57
|
+
checkIntervalMs?: number;
|
|
58
|
+
/** Minimum output length to consider for loop detection */
|
|
59
|
+
minLoopLength?: number;
|
|
60
|
+
/** Error patterns to detect (regexes) */
|
|
61
|
+
errorPatterns?: RegExp[];
|
|
62
|
+
/** Threshold for same file operations before considered stuck (default: 10) */
|
|
63
|
+
toolLoopThreshold?: number;
|
|
64
|
+
/** Time window for tool loop detection (ms, default: 5 minutes) */
|
|
65
|
+
toolLoopWindowMs?: number;
|
|
66
|
+
/** Output lines per minute threshold for flood detection (default: 5000) */
|
|
67
|
+
outputFloodLinesPerMinute?: number;
|
|
68
|
+
/** Minimum duration before flood detection activates (ms, default: 2 minutes) */
|
|
69
|
+
outputFloodMinDurationMs?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_CONFIG: Required<StuckDetectorConfig> = {
|
|
73
|
+
extendedIdleMs: 10 * 60 * 1000, // 10 minutes
|
|
74
|
+
loopThreshold: 3,
|
|
75
|
+
checkIntervalMs: 30 * 1000, // 30 seconds
|
|
76
|
+
minLoopLength: 20, // Minimum chars to consider a meaningful loop
|
|
77
|
+
errorPatterns: [
|
|
78
|
+
/error:/i,
|
|
79
|
+
/failed:/i,
|
|
80
|
+
/exception:/i,
|
|
81
|
+
/timeout/i,
|
|
82
|
+
/connection refused/i,
|
|
83
|
+
/permission denied/i,
|
|
84
|
+
/command not found/i,
|
|
85
|
+
/no such file/i,
|
|
86
|
+
],
|
|
87
|
+
toolLoopThreshold: 10, // Same file operated on 10+ times = likely stuck
|
|
88
|
+
toolLoopWindowMs: 5 * 60 * 1000, // 5 minute window for tool loop detection
|
|
89
|
+
outputFloodLinesPerMinute: 5000, // 5000+ lines/min is abnormal
|
|
90
|
+
outputFloodMinDurationMs: 2 * 60 * 1000, // Wait 2 minutes before flood detection
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Patterns to extract tool invocations from Claude Code output */
|
|
94
|
+
const TOOL_PATTERNS = [
|
|
95
|
+
// Claude Code tool patterns: ⏺ Write(path), ⏺ Read(path), etc.
|
|
96
|
+
/[⏺●]\s*(Read|Write|Edit|Glob|Grep|Bash)\(([^)]+)\)/g,
|
|
97
|
+
// Alternative patterns without symbols
|
|
98
|
+
/\b(Read|Write|Edit)\s*\(\s*([^)]+)\s*\)/g,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
export class StuckDetector extends EventEmitter {
|
|
102
|
+
private config: Required<StuckDetectorConfig>;
|
|
103
|
+
private lastOutputTime = Date.now();
|
|
104
|
+
private recentOutputs: string[] = [];
|
|
105
|
+
private checkInterval: NodeJS.Timeout | null = null;
|
|
106
|
+
private isStuck = false;
|
|
107
|
+
private stuckReason: StuckReason | null = null;
|
|
108
|
+
|
|
109
|
+
// Tool loop detection
|
|
110
|
+
private toolInvocations: ToolInvocation[] = [];
|
|
111
|
+
|
|
112
|
+
// Output flood detection
|
|
113
|
+
private outputLineCount = 0;
|
|
114
|
+
private outputStartTime = Date.now();
|
|
115
|
+
|
|
116
|
+
constructor(config: StuckDetectorConfig = {}) {
|
|
117
|
+
super();
|
|
118
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Start monitoring for stuck conditions.
|
|
123
|
+
* Call this after the agent process starts.
|
|
124
|
+
*/
|
|
125
|
+
start(): void {
|
|
126
|
+
this.stop(); // Clear any existing interval
|
|
127
|
+
this.isStuck = false;
|
|
128
|
+
this.stuckReason = null;
|
|
129
|
+
this.lastOutputTime = Date.now();
|
|
130
|
+
this.recentOutputs = [];
|
|
131
|
+
this.toolInvocations = [];
|
|
132
|
+
this.outputLineCount = 0;
|
|
133
|
+
this.outputStartTime = Date.now();
|
|
134
|
+
|
|
135
|
+
this.checkInterval = setInterval(() => {
|
|
136
|
+
this.checkStuck();
|
|
137
|
+
}, this.config.checkIntervalMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Stop monitoring.
|
|
142
|
+
*/
|
|
143
|
+
stop(): void {
|
|
144
|
+
if (this.checkInterval) {
|
|
145
|
+
clearInterval(this.checkInterval);
|
|
146
|
+
this.checkInterval = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Feed output to the detector.
|
|
152
|
+
* Call this for every output chunk from the agent.
|
|
153
|
+
*/
|
|
154
|
+
onOutput(chunk: string): void {
|
|
155
|
+
this.lastOutputTime = Date.now();
|
|
156
|
+
|
|
157
|
+
// Track output volume (count newlines)
|
|
158
|
+
const lineCount = (chunk.match(/\n/g) || []).length;
|
|
159
|
+
this.outputLineCount += lineCount;
|
|
160
|
+
|
|
161
|
+
// Extract and track tool invocations
|
|
162
|
+
this.extractToolInvocations(chunk);
|
|
163
|
+
|
|
164
|
+
// Normalize and store recent output
|
|
165
|
+
const normalized = this.normalizeOutput(chunk);
|
|
166
|
+
if (normalized.length >= this.config.minLoopLength) {
|
|
167
|
+
this.recentOutputs.push(normalized);
|
|
168
|
+
|
|
169
|
+
// Keep only recent outputs (5x threshold for pattern detection)
|
|
170
|
+
const maxOutputs = this.config.loopThreshold * 5;
|
|
171
|
+
if (this.recentOutputs.length > maxOutputs) {
|
|
172
|
+
this.recentOutputs = this.recentOutputs.slice(-maxOutputs);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If we were stuck, check if we're unstuck now
|
|
177
|
+
if (this.isStuck) {
|
|
178
|
+
this.isStuck = false;
|
|
179
|
+
this.stuckReason = null;
|
|
180
|
+
this.emit('unstuck', { timestamp: Date.now() });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Extract tool invocations from output and track them.
|
|
186
|
+
*/
|
|
187
|
+
private extractToolInvocations(chunk: string): void {
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
|
|
190
|
+
// Strip ANSI codes for cleaner matching
|
|
191
|
+
// eslint-disable-next-line no-control-regex
|
|
192
|
+
const clean = chunk.replace(/\x1B(?:\[[0-9;?]*[A-Za-z]|\].*?(?:\x07|\x1B\\)|[@-Z\\-_])/g, '');
|
|
193
|
+
|
|
194
|
+
// Track what we've already matched in this chunk to prevent duplicates
|
|
195
|
+
const matchedInChunk = new Set<string>();
|
|
196
|
+
|
|
197
|
+
for (const pattern of TOOL_PATTERNS) {
|
|
198
|
+
// Reset lastIndex for global regex
|
|
199
|
+
pattern.lastIndex = 0;
|
|
200
|
+
let match;
|
|
201
|
+
while ((match = pattern.exec(clean)) !== null) {
|
|
202
|
+
const tool = match[1];
|
|
203
|
+
const target = match[2].trim();
|
|
204
|
+
|
|
205
|
+
// Normalize file paths (remove ~ prefix, trailing whitespace)
|
|
206
|
+
const normalizedTarget = target
|
|
207
|
+
.replace(/^~\//, '')
|
|
208
|
+
.replace(/\s+$/, '');
|
|
209
|
+
|
|
210
|
+
// Deduplicate within this chunk (multiple patterns may match same invocation)
|
|
211
|
+
const key = `${tool}:${normalizedTarget}`;
|
|
212
|
+
if (matchedInChunk.has(key)) continue;
|
|
213
|
+
matchedInChunk.add(key);
|
|
214
|
+
|
|
215
|
+
this.toolInvocations.push({
|
|
216
|
+
tool,
|
|
217
|
+
target: normalizedTarget,
|
|
218
|
+
timestamp: now,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Prune old invocations outside the window
|
|
224
|
+
const windowStart = now - this.config.toolLoopWindowMs;
|
|
225
|
+
this.toolInvocations = this.toolInvocations.filter(
|
|
226
|
+
inv => inv.timestamp >= windowStart
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Normalize output for comparison (strip ANSI, trim, lowercase).
|
|
232
|
+
*/
|
|
233
|
+
private normalizeOutput(output: string): string {
|
|
234
|
+
// Strip ANSI escape codes
|
|
235
|
+
// eslint-disable-next-line no-control-regex
|
|
236
|
+
let normalized = output.replace(/\x1B(?:\[[0-9;?]*[A-Za-z]|\].*?(?:\x07|\x1B\\)|[@-Z\\-_])/g, '');
|
|
237
|
+
// Normalize whitespace
|
|
238
|
+
normalized = normalized.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
239
|
+
return normalized;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Check for stuck conditions.
|
|
244
|
+
*/
|
|
245
|
+
private checkStuck(): void {
|
|
246
|
+
// Don't re-emit if already stuck
|
|
247
|
+
if (this.isStuck) return;
|
|
248
|
+
|
|
249
|
+
// Check 1: Extended idle
|
|
250
|
+
const idleDuration = Date.now() - this.lastOutputTime;
|
|
251
|
+
if (idleDuration >= this.config.extendedIdleMs) {
|
|
252
|
+
this.emitStuck({
|
|
253
|
+
reason: 'extended_idle',
|
|
254
|
+
details: `No output for ${Math.round(idleDuration / 60000)} minutes`,
|
|
255
|
+
timestamp: Date.now(),
|
|
256
|
+
idleDurationMs: idleDuration,
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Check 2: Error loop
|
|
262
|
+
const errorLoop = this.detectErrorLoop();
|
|
263
|
+
if (errorLoop) {
|
|
264
|
+
this.emitStuck({
|
|
265
|
+
reason: 'error_loop',
|
|
266
|
+
details: `Same error repeated ${errorLoop.count} times`,
|
|
267
|
+
timestamp: Date.now(),
|
|
268
|
+
repeatedContent: errorLoop.error,
|
|
269
|
+
repetitions: errorLoop.count,
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Check 3: Output loop
|
|
275
|
+
const outputLoop = this.detectOutputLoop();
|
|
276
|
+
if (outputLoop) {
|
|
277
|
+
this.emitStuck({
|
|
278
|
+
reason: 'output_loop',
|
|
279
|
+
details: `Same output repeated ${outputLoop.count} times`,
|
|
280
|
+
timestamp: Date.now(),
|
|
281
|
+
repeatedContent: outputLoop.output.substring(0, 100),
|
|
282
|
+
repetitions: outputLoop.count,
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check 4: Tool loop (same file operated on repeatedly)
|
|
288
|
+
const toolLoop = this.detectToolLoop();
|
|
289
|
+
if (toolLoop) {
|
|
290
|
+
this.emitStuck({
|
|
291
|
+
reason: 'tool_loop',
|
|
292
|
+
details: `${toolLoop.tool} called on "${toolLoop.target}" ${toolLoop.count} times in ${Math.round(this.config.toolLoopWindowMs / 60000)} minutes`,
|
|
293
|
+
timestamp: Date.now(),
|
|
294
|
+
targetFile: toolLoop.target,
|
|
295
|
+
toolName: toolLoop.tool,
|
|
296
|
+
repetitions: toolLoop.count,
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Check 5: Output flood (abnormally high output rate)
|
|
302
|
+
const flood = this.detectOutputFlood();
|
|
303
|
+
if (flood) {
|
|
304
|
+
this.emitStuck({
|
|
305
|
+
reason: 'output_flood',
|
|
306
|
+
details: `Abnormally high output: ${flood.linesPerMinute.toFixed(0)} lines/min over ${Math.round(flood.durationMs / 60000)} minutes`,
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
linesPerMinute: flood.linesPerMinute,
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Detect repeated error messages.
|
|
316
|
+
*/
|
|
317
|
+
private detectErrorLoop(): { error: string; count: number } | null {
|
|
318
|
+
const errorOutputs: string[] = [];
|
|
319
|
+
|
|
320
|
+
for (const output of this.recentOutputs) {
|
|
321
|
+
for (const pattern of this.config.errorPatterns) {
|
|
322
|
+
if (pattern.test(output)) {
|
|
323
|
+
errorOutputs.push(output);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (errorOutputs.length < this.config.loopThreshold) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Check if the same error appears repeatedly
|
|
334
|
+
const errorCounts = new Map<string, number>();
|
|
335
|
+
for (const error of errorOutputs) {
|
|
336
|
+
const count = (errorCounts.get(error) || 0) + 1;
|
|
337
|
+
errorCounts.set(error, count);
|
|
338
|
+
|
|
339
|
+
if (count >= this.config.loopThreshold) {
|
|
340
|
+
return { error, count };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Detect repeated output patterns (not necessarily errors).
|
|
349
|
+
*/
|
|
350
|
+
private detectOutputLoop(): { output: string; count: number } | null {
|
|
351
|
+
if (this.recentOutputs.length < this.config.loopThreshold) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check for identical consecutive outputs
|
|
356
|
+
const outputCounts = new Map<string, number>();
|
|
357
|
+
for (const output of this.recentOutputs) {
|
|
358
|
+
const count = (outputCounts.get(output) || 0) + 1;
|
|
359
|
+
outputCounts.set(output, count);
|
|
360
|
+
|
|
361
|
+
if (count >= this.config.loopThreshold) {
|
|
362
|
+
return { output, count };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Detect when the same file is being operated on repeatedly.
|
|
371
|
+
* This catches cases like an agent repeatedly reading/writing the same file
|
|
372
|
+
* in a loop, even if the output content differs each time.
|
|
373
|
+
*/
|
|
374
|
+
private detectToolLoop(): { tool: string; target: string; count: number } | null {
|
|
375
|
+
if (this.toolInvocations.length < this.config.toolLoopThreshold) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Count operations per file (combining all tool types)
|
|
380
|
+
const fileCounts = new Map<string, { count: number; tools: Set<string> }>();
|
|
381
|
+
|
|
382
|
+
for (const inv of this.toolInvocations) {
|
|
383
|
+
const existing = fileCounts.get(inv.target);
|
|
384
|
+
if (existing) {
|
|
385
|
+
existing.count++;
|
|
386
|
+
existing.tools.add(inv.tool);
|
|
387
|
+
} else {
|
|
388
|
+
fileCounts.set(inv.target, { count: 1, tools: new Set([inv.tool]) });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Find files that exceed the threshold
|
|
393
|
+
for (const [target, data] of Array.from(fileCounts.entries())) {
|
|
394
|
+
if (data.count >= this.config.toolLoopThreshold) {
|
|
395
|
+
// Report the most common tool used on this file
|
|
396
|
+
const toolCounts = new Map<string, number>();
|
|
397
|
+
for (const inv of this.toolInvocations) {
|
|
398
|
+
if (inv.target === target) {
|
|
399
|
+
toolCounts.set(inv.tool, (toolCounts.get(inv.tool) || 0) + 1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let maxTool = 'Unknown';
|
|
404
|
+
let maxCount = 0;
|
|
405
|
+
for (const [tool, toolCount] of Array.from(toolCounts.entries())) {
|
|
406
|
+
if (toolCount > maxCount) {
|
|
407
|
+
maxTool = tool;
|
|
408
|
+
maxCount = toolCount;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { tool: maxTool, target, count: data.count };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Detect abnormally high output rates that suggest an infinite loop.
|
|
421
|
+
* Only triggers after minimum duration to avoid false positives during
|
|
422
|
+
* normal high-output operations (like builds or tests).
|
|
423
|
+
*/
|
|
424
|
+
private detectOutputFlood(): { linesPerMinute: number; durationMs: number } | null {
|
|
425
|
+
const durationMs = Date.now() - this.outputStartTime;
|
|
426
|
+
|
|
427
|
+
// Don't check until minimum duration has passed
|
|
428
|
+
if (durationMs < this.config.outputFloodMinDurationMs) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const durationMinutes = durationMs / 60000;
|
|
433
|
+
const linesPerMinute = this.outputLineCount / durationMinutes;
|
|
434
|
+
|
|
435
|
+
if (linesPerMinute >= this.config.outputFloodLinesPerMinute) {
|
|
436
|
+
return { linesPerMinute, durationMs };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Emit stuck event.
|
|
444
|
+
*/
|
|
445
|
+
private emitStuck(event: StuckEvent): void {
|
|
446
|
+
this.isStuck = true;
|
|
447
|
+
this.stuckReason = event.reason;
|
|
448
|
+
this.emit('stuck', event);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Check if currently detected as stuck.
|
|
453
|
+
*/
|
|
454
|
+
getIsStuck(): boolean {
|
|
455
|
+
return this.isStuck;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Get the reason for being stuck (if stuck).
|
|
460
|
+
*/
|
|
461
|
+
getStuckReason(): StuckReason | null {
|
|
462
|
+
return this.stuckReason;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Get time since last output in milliseconds.
|
|
467
|
+
*/
|
|
468
|
+
getIdleDuration(): number {
|
|
469
|
+
return Date.now() - this.lastOutputTime;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Reset state.
|
|
474
|
+
*/
|
|
475
|
+
reset(): void {
|
|
476
|
+
this.isStuck = false;
|
|
477
|
+
this.stuckReason = null;
|
|
478
|
+
this.lastOutputTime = Date.now();
|
|
479
|
+
this.recentOutputs = [];
|
|
480
|
+
this.toolInvocations = [];
|
|
481
|
+
this.outputLineCount = 0;
|
|
482
|
+
this.outputStartTime = Date.now();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Get current output statistics (useful for debugging).
|
|
487
|
+
*/
|
|
488
|
+
getOutputStats(): { lineCount: number; durationMs: number; linesPerMinute: number } {
|
|
489
|
+
const durationMs = Date.now() - this.outputStartTime;
|
|
490
|
+
const durationMinutes = Math.max(durationMs / 60000, 0.001); // Avoid division by zero
|
|
491
|
+
return {
|
|
492
|
+
lineCount: this.outputLineCount,
|
|
493
|
+
durationMs,
|
|
494
|
+
linesPerMinute: this.outputLineCount / durationMinutes,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Get recent tool invocations (useful for debugging).
|
|
500
|
+
*/
|
|
501
|
+
getToolInvocations(): ToolInvocation[] {
|
|
502
|
+
return [...this.toolInvocations];
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Create a stuck detector with default configuration.
|
|
508
|
+
*/
|
|
509
|
+
export function createStuckDetector(config: StuckDetectorConfig = {}): StuckDetector {
|
|
510
|
+
return new StuckDetector(config);
|
|
511
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tmux Resolver Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
getTmuxPath,
|
|
8
|
+
resolveTmux,
|
|
9
|
+
isTmuxAvailable,
|
|
10
|
+
checkTmuxVersion,
|
|
11
|
+
getPlatformIdentifier,
|
|
12
|
+
TmuxNotFoundError,
|
|
13
|
+
getBundledTmuxDir,
|
|
14
|
+
getBundledTmuxPath,
|
|
15
|
+
MIN_TMUX_VERSION,
|
|
16
|
+
} from './tmux-resolver.js';
|
|
17
|
+
|
|
18
|
+
describe('tmux-resolver', () => {
|
|
19
|
+
describe('constants', () => {
|
|
20
|
+
it('should export MIN_TMUX_VERSION', () => {
|
|
21
|
+
expect(MIN_TMUX_VERSION).toBe('3.0');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should export bundled path functions', () => {
|
|
25
|
+
expect(typeof getBundledTmuxDir).toBe('function');
|
|
26
|
+
expect(typeof getBundledTmuxPath).toBe('function');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('getPlatformIdentifier', () => {
|
|
31
|
+
it('should return platform identifier for supported platforms', () => {
|
|
32
|
+
const identifier = getPlatformIdentifier();
|
|
33
|
+
// Should be one of the supported platforms or null
|
|
34
|
+
if (identifier !== null) {
|
|
35
|
+
expect([
|
|
36
|
+
'macos-arm64',
|
|
37
|
+
'macos-x86_64',
|
|
38
|
+
'linux-arm64',
|
|
39
|
+
'linux-x86_64',
|
|
40
|
+
]).toContain(identifier);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('TmuxNotFoundError', () => {
|
|
46
|
+
it('should be an Error instance', () => {
|
|
47
|
+
const error = new TmuxNotFoundError();
|
|
48
|
+
expect(error).toBeInstanceOf(Error);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should have name TmuxNotFoundError', () => {
|
|
52
|
+
const error = new TmuxNotFoundError();
|
|
53
|
+
expect(error.name).toBe('TmuxNotFoundError');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should include installation instructions', () => {
|
|
57
|
+
const error = new TmuxNotFoundError();
|
|
58
|
+
expect(error.message).toContain('tmux is required');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('resolveTmux', () => {
|
|
63
|
+
it('should return TmuxInfo or null', () => {
|
|
64
|
+
const result = resolveTmux();
|
|
65
|
+
if (result !== null) {
|
|
66
|
+
expect(result).toHaveProperty('path');
|
|
67
|
+
expect(result).toHaveProperty('version');
|
|
68
|
+
expect(result).toHaveProperty('isBundled');
|
|
69
|
+
expect(typeof result.path).toBe('string');
|
|
70
|
+
expect(typeof result.version).toBe('string');
|
|
71
|
+
expect(typeof result.isBundled).toBe('boolean');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('isTmuxAvailable', () => {
|
|
77
|
+
it('should return boolean', () => {
|
|
78
|
+
expect(typeof isTmuxAvailable()).toBe('boolean');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('checkTmuxVersion', () => {
|
|
83
|
+
it('should return version check result', () => {
|
|
84
|
+
const result = checkTmuxVersion();
|
|
85
|
+
expect(result).toHaveProperty('ok');
|
|
86
|
+
expect(result).toHaveProperty('version');
|
|
87
|
+
expect(result).toHaveProperty('minimum');
|
|
88
|
+
expect(typeof result.ok).toBe('boolean');
|
|
89
|
+
expect(result.minimum).toBe(MIN_TMUX_VERSION);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('getTmuxPath', () => {
|
|
94
|
+
it('should return path string or throw TmuxNotFoundError', () => {
|
|
95
|
+
try {
|
|
96
|
+
const path = getTmuxPath();
|
|
97
|
+
expect(typeof path).toBe('string');
|
|
98
|
+
expect(path.length).toBeGreaterThan(0);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
expect(error).toBeInstanceOf(TmuxNotFoundError);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|