groove-dev 0.27.165 → 0.27.168
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/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/adaptive.js +37 -10
- package/node_modules/@groove-dev/daemon/src/classifier.js +3 -1
- package/node_modules/@groove-dev/daemon/src/index.js +10 -9
- package/node_modules/@groove-dev/daemon/src/memory.js +9 -1
- package/node_modules/@groove-dev/daemon/src/process.js +14 -5
- package/node_modules/@groove-dev/daemon/src/registry.js +18 -3
- package/node_modules/@groove-dev/daemon/src/rotator.js +81 -10
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +38 -25
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +28 -21
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-SZBexPhJ.js → index-CSMIQsrG.js} +94 -94
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +32 -2
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-content.jsx +4 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +11 -6
- package/node_modules/@groove-dev/gui/src/stores/groove.js +8 -4
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +9 -0
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/adaptive.js +37 -10
- package/packages/daemon/src/classifier.js +3 -1
- package/packages/daemon/src/index.js +10 -9
- package/packages/daemon/src/memory.js +9 -1
- package/packages/daemon/src/process.js +14 -5
- package/packages/daemon/src/registry.js +18 -3
- package/packages/daemon/src/rotator.js +81 -10
- package/packages/daemon/src/routes/agents.js +38 -25
- package/packages/daemon/src/tunnel-manager.js +28 -21
- package/packages/gui/dist/assets/{index-SZBexPhJ.js → index-CSMIQsrG.js} +94 -94
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-panel.jsx +1 -1
- package/packages/gui/src/components/editor/terminal.jsx +32 -2
- package/packages/gui/src/components/fleet/fleet-content.jsx +4 -1
- package/packages/gui/src/components/fleet/fleet-pane.jsx +11 -6
- package/packages/gui/src/stores/groove.js +8 -4
- package/packages/gui/src/stores/slices/agents-slice.js +9 -0
- package/terminal/Screenshot_2026-05-19_at_12.20.15_PM.png +0 -0
|
@@ -110,13 +110,43 @@ export class AdaptiveThresholds {
|
|
|
110
110
|
const selfManages = options.selfManagesContext ?? false;
|
|
111
111
|
let score = 70; // Baseline: decent session
|
|
112
112
|
|
|
113
|
-
// Error
|
|
113
|
+
// --- Error-related penalties ---
|
|
114
|
+
// For self-managing providers, errorCount/errorTrend/repetitions are often
|
|
115
|
+
// correlated: running scripts that produce error output triggers all three.
|
|
116
|
+
// Cap the combined penalty so one root cause can't stack across signals.
|
|
114
117
|
const errorCount = signals.errorCount || 0;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// Repetitions: 3+ Write/Edit to the same file in a sliding window
|
|
118
|
+
const toolOutputErrors = signals.toolOutputErrors || 0;
|
|
118
119
|
const repetitions = signals.repetitions || 0;
|
|
119
|
-
|
|
120
|
+
const errorTrend = signals.errorTrend || 0;
|
|
121
|
+
|
|
122
|
+
let errorPenalty, repetitionPenalty, errorTrendPenalty;
|
|
123
|
+
|
|
124
|
+
if (selfManages) {
|
|
125
|
+
// Tool output errors (is_error on tool results from scripts/tests) get
|
|
126
|
+
// a reduced rate — they're normal workflow, not agent failures
|
|
127
|
+
const agentErrors = errorCount - toolOutputErrors;
|
|
128
|
+
errorPenalty = (Math.max(agentErrors, 0) * 2) + (toolOutputErrors * 1);
|
|
129
|
+
repetitionPenalty = Math.min(repetitions * 6, 30);
|
|
130
|
+
errorTrendPenalty = errorTrend > 0 ? errorTrend * 3 : 0;
|
|
131
|
+
|
|
132
|
+
// Cap: all error-related signals combined max -35 for self-managing
|
|
133
|
+
const totalErrorRelated = errorPenalty + repetitionPenalty + errorTrendPenalty;
|
|
134
|
+
if (totalErrorRelated > 35) {
|
|
135
|
+
const scale = 35 / totalErrorRelated;
|
|
136
|
+
errorPenalty = Math.round(errorPenalty * scale);
|
|
137
|
+
repetitionPenalty = Math.round(repetitionPenalty * scale);
|
|
138
|
+
errorTrendPenalty = Math.round(errorTrendPenalty * scale);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
errorPenalty = errorCount * 5;
|
|
142
|
+
repetitionPenalty = Math.min(repetitions * 6, 30);
|
|
143
|
+
errorTrendPenalty = errorTrend > 0 ? errorTrend * 6 : 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
score -= errorPenalty;
|
|
147
|
+
score -= repetitionPenalty;
|
|
148
|
+
score -= errorTrendPenalty;
|
|
149
|
+
if (errorTrend < 0) score += 3; // Errors decreasing = good
|
|
120
150
|
|
|
121
151
|
// Out-of-scope access: each violation costs 10 points
|
|
122
152
|
const scopeViolations = signals.scopeViolations || 0;
|
|
@@ -134,11 +164,6 @@ export class AdaptiveThresholds {
|
|
|
134
164
|
const fileChurn = signals.fileChurn || 0;
|
|
135
165
|
score -= fileChurn * 10;
|
|
136
166
|
|
|
137
|
-
// Error trend: increasing errors in second half = degradation
|
|
138
|
-
const errorTrend = signals.errorTrend || 0;
|
|
139
|
-
if (errorTrend > 0) score -= errorTrend * (selfManages ? 3 : 6);
|
|
140
|
-
if (errorTrend < 0) score += 3;
|
|
141
|
-
|
|
142
167
|
// Files written: productivity signal
|
|
143
168
|
const filesWritten = signals.filesWritten || 0;
|
|
144
169
|
score += Math.min(filesWritten * 2, 10); // Cap at +10
|
|
@@ -182,6 +207,7 @@ export class AdaptiveThresholds {
|
|
|
182
207
|
extractSignals(entries, agentScope) {
|
|
183
208
|
const signals = {
|
|
184
209
|
errorCount: 0,
|
|
210
|
+
toolOutputErrors: 0, // is_error tool results (script output, test failures — not agent errors)
|
|
185
211
|
repetitions: 0,
|
|
186
212
|
scopeViolations: 0,
|
|
187
213
|
toolCalls: 0,
|
|
@@ -208,6 +234,7 @@ export class AdaptiveThresholds {
|
|
|
208
234
|
|
|
209
235
|
if (entry.type === 'error') {
|
|
210
236
|
signals.errorCount++;
|
|
237
|
+
if (entry.source === 'tool_output') signals.toolOutputErrors++;
|
|
211
238
|
}
|
|
212
239
|
|
|
213
240
|
// Track assistant output lengths for decay detection
|
|
@@ -63,7 +63,9 @@ export class TaskClassifier {
|
|
|
63
63
|
}
|
|
64
64
|
if (block.type === 'tool_result') {
|
|
65
65
|
if (block.is_error) {
|
|
66
|
-
|
|
66
|
+
// Tag source so the quality scorer can distinguish tool output errors
|
|
67
|
+
// (script failures, test output) from agent behavioral errors
|
|
68
|
+
window.push({ type: 'error', source: 'tool_output', timestamp: Date.now() });
|
|
67
69
|
extracted = true;
|
|
68
70
|
}
|
|
69
71
|
}
|
|
@@ -312,16 +312,17 @@ export class Daemon {
|
|
|
312
312
|
|
|
313
313
|
// Single unified registry change listener (broadcast + file I/O + coordination)
|
|
314
314
|
this.registry.on('change', (delta) => {
|
|
315
|
-
if (delta && delta.
|
|
316
|
-
this.broadcast({ type: 'state:delta', data: { changed: [], removed: delta.removed } });
|
|
317
|
-
} else if (delta && delta.changed) {
|
|
318
|
-
const changedAgents = delta.changed
|
|
319
|
-
.map((id) => this.registry.get(id))
|
|
320
|
-
.filter(Boolean);
|
|
321
|
-
this.broadcast({ type: 'state:delta', data: { changed: enrichAgents(changedAgents), removed: [] } });
|
|
322
|
-
} else {
|
|
323
|
-
// Fallback: full state broadcast
|
|
315
|
+
if (!delta || (!delta.removed && !delta.changed)) {
|
|
324
316
|
this.broadcast({ type: 'state', data: enrichAgents(this.registry.getAll()) });
|
|
317
|
+
} else {
|
|
318
|
+
const data = { changed: [], removed: [] };
|
|
319
|
+
if (delta.removed?.length) data.removed = delta.removed;
|
|
320
|
+
if (delta.changed?.length) {
|
|
321
|
+
data.changed = enrichAgents(delta.changed.map((id) => this.registry.get(id)).filter(Boolean));
|
|
322
|
+
}
|
|
323
|
+
if (data.changed.length > 0 || data.removed.length > 0) {
|
|
324
|
+
this.broadcast({ type: 'state:delta', data });
|
|
325
|
+
}
|
|
325
326
|
}
|
|
326
327
|
_debouncedRegistryIo();
|
|
327
328
|
this.teams.onAgentChange();
|
|
@@ -206,6 +206,14 @@ export class MemoryStore {
|
|
|
206
206
|
|
|
207
207
|
const nextN = (chain[0]?.rotationN || 0) + 1;
|
|
208
208
|
|
|
209
|
+
// Strip sections that embed previous chain content to prevent recursive
|
|
210
|
+
// nesting — each chain entry should contain only its OWN brief, not copies
|
|
211
|
+
// of all prior briefs. The "Rotation History" and "Known Issues" sections
|
|
212
|
+
// are the primary vectors for this duplication.
|
|
213
|
+
let briefText = entry.brief || '(no brief)';
|
|
214
|
+
briefText = briefText.replace(/## Rotation History[\s\S]*?(?=## [A-Z]|$)/, '').trim();
|
|
215
|
+
briefText = briefText.replace(/## Known Issues & Fixes[\s\S]*?(?=## [A-Z]|$)/, '').trim();
|
|
216
|
+
|
|
209
217
|
const block = [
|
|
210
218
|
`## Rotation ${nextN} — ${entry.timestamp || new Date().toISOString()} (${entry.agentId || '?'} → ${entry.newAgentId || '?'})`,
|
|
211
219
|
`**Reason:** ${entry.reason || 'unknown'}`,
|
|
@@ -213,7 +221,7 @@ export class MemoryStore {
|
|
|
213
221
|
entry.contextUsage != null ? `**Context at rotation:** ${Math.round(entry.contextUsage * 100)}%` : '',
|
|
214
222
|
'',
|
|
215
223
|
'**Brief summary:**',
|
|
216
|
-
truncate(
|
|
224
|
+
truncate(briefText, HANDOFF_BRIEF_MAX_CHARS),
|
|
217
225
|
'',
|
|
218
226
|
].filter(Boolean).join('\n');
|
|
219
227
|
|
|
@@ -1975,7 +1975,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1975
1975
|
|
|
1976
1976
|
if (existing && (existing.status === 'completed' || existing.status === 'stopped' || existing.status === 'crashed' || existing.status === 'killed')) {
|
|
1977
1977
|
config.name = existing.name;
|
|
1978
|
-
registry.remove(existing.id);
|
|
1978
|
+
registry.remove(existing.id, { silent: true });
|
|
1979
1979
|
this.daemon.locks.release(existing.id);
|
|
1980
1980
|
}
|
|
1981
1981
|
|
|
@@ -2516,11 +2516,13 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2516
2516
|
const config = { ...agent };
|
|
2517
2517
|
const sessionId = agent.sessionId;
|
|
2518
2518
|
|
|
2519
|
-
// Stop if running, then remove old entry so we can re-register with same name
|
|
2519
|
+
// Stop if running, then remove old entry so we can re-register with same name.
|
|
2520
|
+
// Silent removal: the add() below will flush both removed+changed in one broadcast,
|
|
2521
|
+
// preventing the GUI from seeing an empty agents list between remove and re-add.
|
|
2520
2522
|
if (this.handles.has(agentId)) {
|
|
2521
2523
|
await this.kill(agentId);
|
|
2522
2524
|
}
|
|
2523
|
-
registry.remove(agentId);
|
|
2525
|
+
registry.remove(agentId, { silent: true });
|
|
2524
2526
|
locks.release(agentId);
|
|
2525
2527
|
|
|
2526
2528
|
// Build resume command
|
|
@@ -2648,7 +2650,7 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2648
2650
|
if (this.handles.has(agentId)) {
|
|
2649
2651
|
await this.kill(agentId);
|
|
2650
2652
|
}
|
|
2651
|
-
registry.remove(agentId);
|
|
2653
|
+
registry.remove(agentId, { silent: true });
|
|
2652
2654
|
locks.release(agentId);
|
|
2653
2655
|
|
|
2654
2656
|
const newAgent = await this.spawn({
|
|
@@ -2698,7 +2700,7 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2698
2700
|
if (this.handles.has(agentId)) {
|
|
2699
2701
|
await this.kill(agentId);
|
|
2700
2702
|
}
|
|
2701
|
-
registry.remove(agentId);
|
|
2703
|
+
registry.remove(agentId, { silent: true });
|
|
2702
2704
|
locks.release(agentId);
|
|
2703
2705
|
|
|
2704
2706
|
const newAgent = registry.add({
|
|
@@ -3005,6 +3007,10 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
3005
3007
|
const agent = this.daemon.registry.get(agentId);
|
|
3006
3008
|
const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
|
|
3007
3009
|
|
|
3010
|
+
if (source === 'user' && this.daemon.rotator) {
|
|
3011
|
+
this.daemon.rotator.recordUserMessage(agentId);
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3008
3014
|
if (this.daemon.trajectoryCapture) {
|
|
3009
3015
|
try { this.daemon.trajectoryCapture.onUserMessage(agentId, message, source); } catch (e) { /* fail silent */ }
|
|
3010
3016
|
}
|
|
@@ -3025,6 +3031,9 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
3025
3031
|
const agent = this.daemon.registry.get(agentId);
|
|
3026
3032
|
const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
|
|
3027
3033
|
this.pendingMessages.set(agentId, { message: wrapped, timestamp: Date.now() });
|
|
3034
|
+
if (this.daemon.rotator) {
|
|
3035
|
+
this.daemon.rotator.recordUserMessage(agentId);
|
|
3036
|
+
}
|
|
3028
3037
|
this.daemon.broadcast({ type: 'agent:message_queued', agentId, message: wrapped });
|
|
3029
3038
|
}
|
|
3030
3039
|
|
|
@@ -12,6 +12,7 @@ export class Registry extends EventEmitter {
|
|
|
12
12
|
this.state = state;
|
|
13
13
|
this.agents = new Map();
|
|
14
14
|
this._counters = new Map();
|
|
15
|
+
this._pendingRemovals = [];
|
|
15
16
|
this._initCounters();
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -62,7 +63,10 @@ export class Registry extends EventEmitter {
|
|
|
62
63
|
};
|
|
63
64
|
|
|
64
65
|
this.agents.set(agent.id, agent);
|
|
65
|
-
|
|
66
|
+
const removed = this._pendingRemovals.splice(0);
|
|
67
|
+
const delta = { changed: [agent.id] };
|
|
68
|
+
if (removed.length > 0) delta.removed = removed;
|
|
69
|
+
this.emit('change', delta);
|
|
66
70
|
return agent;
|
|
67
71
|
}
|
|
68
72
|
|
|
@@ -89,15 +93,26 @@ export class Registry extends EventEmitter {
|
|
|
89
93
|
return agent;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
remove(id) {
|
|
96
|
+
remove(id, { silent = false } = {}) {
|
|
93
97
|
const agent = this.agents.get(id);
|
|
94
98
|
if (!agent) return false;
|
|
95
99
|
|
|
96
100
|
this.agents.delete(id);
|
|
97
|
-
|
|
101
|
+
if (silent) {
|
|
102
|
+
this._pendingRemovals.push(id);
|
|
103
|
+
} else {
|
|
104
|
+
this.emit('change', { removed: [id] });
|
|
105
|
+
}
|
|
98
106
|
return true;
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
flushPendingRemovals() {
|
|
110
|
+
const removed = this._pendingRemovals.splice(0);
|
|
111
|
+
if (removed.length > 0) {
|
|
112
|
+
this.emit('change', { removed });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
findByRole(role) {
|
|
102
117
|
return this.getAll().filter((a) => a.role === role);
|
|
103
118
|
}
|
|
@@ -35,6 +35,7 @@ export class Rotator extends EventEmitter {
|
|
|
35
35
|
this.lastRotationTime = new Map(); // agentId -> timestamp of last rotation
|
|
36
36
|
this._lastContextState = new Map(); // agentId -> { contextUsage, timestamp }
|
|
37
37
|
this.compactionCounts = new Map(); // agentId -> number of natural compactions
|
|
38
|
+
this.userMessageTimes = new Map(); // agentId -> timestamp of last user message
|
|
38
39
|
this.enabled = false;
|
|
39
40
|
this.liveScores = {};
|
|
40
41
|
this.scoreHistory = {};
|
|
@@ -118,6 +119,10 @@ export class Rotator extends EventEmitter {
|
|
|
118
119
|
this.enabled = false;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
recordUserMessage(agentId) {
|
|
123
|
+
this.userMessageTimes.set(agentId, Date.now());
|
|
124
|
+
}
|
|
125
|
+
|
|
121
126
|
_isOnCooldown(agentId, cooldownMs = COOLDOWN_MS) {
|
|
122
127
|
const lastTime = this.lastRotationTime.get(agentId);
|
|
123
128
|
if (!lastTime) return false;
|
|
@@ -284,14 +289,13 @@ export class Rotator extends EventEmitter {
|
|
|
284
289
|
if (this._isOnCooldown(agent.id, QUALITY_COOLDOWN_MS)) continue;
|
|
285
290
|
|
|
286
291
|
// Effective quality threshold depends on provider type.
|
|
287
|
-
// Self-managing providers (Claude Code): threshold =
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// A threshold of 40 (the default) kills debugging sessions at ~8 minutes.
|
|
292
|
+
// Self-managing providers (Claude Code): threshold = 25. Only rotate on
|
|
293
|
+
// severe degradation. Normal debugging naturally produces errors, retries,
|
|
294
|
+
// and bash repetitions — the scoring model treats these as degradation
|
|
295
|
+
// but they're expected behavior during investigation.
|
|
292
296
|
let effectiveQualityThreshold;
|
|
293
297
|
if (selfManagesContext) {
|
|
294
|
-
effectiveQualityThreshold =
|
|
298
|
+
effectiveQualityThreshold = 25;
|
|
295
299
|
} else {
|
|
296
300
|
effectiveQualityThreshold = QUALITY_THRESHOLD;
|
|
297
301
|
if (compactions >= 3) effectiveQualityThreshold = 55;
|
|
@@ -300,10 +304,27 @@ export class Rotator extends EventEmitter {
|
|
|
300
304
|
}
|
|
301
305
|
}
|
|
302
306
|
|
|
307
|
+
// Context-awareness guard for self-managing providers: if the provider
|
|
308
|
+
// recently compacted and context is low, it's managing itself — the
|
|
309
|
+
// quality score alone is too unreliable to override that.
|
|
310
|
+
if (selfManagesContext && agent.contextUsage < 0.6) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// User-activity guard: if the user sent a message recently, the cost of
|
|
315
|
+
// disrupting the session is much higher than letting it continue. Require
|
|
316
|
+
// catastrophic degradation (score < 5) before rotating.
|
|
317
|
+
const lastUserMsg = this.userMessageTimes.get(agent.id);
|
|
318
|
+
const userActiveRecently = lastUserMsg && (Date.now() - lastUserMsg) < 5 * 60 * 1000;
|
|
319
|
+
if (userActiveRecently) {
|
|
320
|
+
effectiveQualityThreshold = 5;
|
|
321
|
+
}
|
|
322
|
+
|
|
303
323
|
const quality = this.scoreLiveSession(agent);
|
|
304
324
|
if (quality.hasEnoughData && quality.score < effectiveQualityThreshold) {
|
|
305
325
|
// For self-managing providers, effectiveQualityThreshold IS the severe
|
|
306
|
-
// threshold (
|
|
326
|
+
// threshold (25, or 5 if user is active) — any score below it is
|
|
327
|
+
// catastrophic, rotate immediately.
|
|
307
328
|
// For others, severe = < 25, moderate = 25-40/55.
|
|
308
329
|
const severeThreshold = selfManagesContext ? effectiveQualityThreshold : 25;
|
|
309
330
|
if (quality.score < severeThreshold) {
|
|
@@ -331,12 +352,36 @@ export class Rotator extends EventEmitter {
|
|
|
331
352
|
}
|
|
332
353
|
}
|
|
333
354
|
|
|
355
|
+
_detectIdleLoop(agentName) {
|
|
356
|
+
const thirtyMinAgo = Date.now() - 30 * 60 * 1000;
|
|
357
|
+
const recentLowToken = this.rotationHistory.filter(r =>
|
|
358
|
+
r.agentName === agentName &&
|
|
359
|
+
new Date(r.timestamp).getTime() > thirtyMinAgo &&
|
|
360
|
+
(r.oldTokens || 0) < 2000
|
|
361
|
+
);
|
|
362
|
+
return recentLowToken.length >= 3;
|
|
363
|
+
}
|
|
364
|
+
|
|
334
365
|
async rotate(agentId, options = {}) {
|
|
335
366
|
const { registry, processes, journalist } = this.daemon;
|
|
336
367
|
const agent = registry.get(agentId);
|
|
337
368
|
if (!agent) throw new Error(`Agent ${agentId} not found`);
|
|
338
369
|
if (this.rotating.has(agentId)) throw new Error(`Agent ${agentId} is already rotating`);
|
|
339
370
|
|
|
371
|
+
// Idle rotation loop detection: if this agent name has completed 3+ times
|
|
372
|
+
// in the last 30 minutes with minimal tokens, the system is spinning — each
|
|
373
|
+
// replacement arrives blind, does nothing, and completes. Stop the cycle.
|
|
374
|
+
if (this._detectIdleLoop(agent.name)) {
|
|
375
|
+
console.warn(` Rotator: ${agent.name} is in an idle rotation loop (3+ low-token rotations in 30 min). Skipping respawn — waiting for user input.`);
|
|
376
|
+
this.daemon.broadcast({
|
|
377
|
+
type: 'rotation:blocked',
|
|
378
|
+
agentId,
|
|
379
|
+
agentName: agent.name,
|
|
380
|
+
reason: 'idle_loop_detected',
|
|
381
|
+
});
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
340
385
|
this.rotating.add(agentId);
|
|
341
386
|
|
|
342
387
|
this.daemon.broadcast({
|
|
@@ -386,6 +431,30 @@ export class Rotator extends EventEmitter {
|
|
|
386
431
|
}
|
|
387
432
|
}
|
|
388
433
|
|
|
434
|
+
// Brief quality validation: if an AUTOMATIC rotation produces a brief
|
|
435
|
+
// with too little meaningful content, the new agent will arrive blind.
|
|
436
|
+
// Block it rather than spawning an agent that can't continue the work.
|
|
437
|
+
// Manual rotations (user-requested) are never blocked — the user accepts the risk.
|
|
438
|
+
const isAutoRotation = options.reason && options.reason !== 'manual';
|
|
439
|
+
if (isAutoRotation && !usedConversationThread) {
|
|
440
|
+
const briefContent = brief
|
|
441
|
+
.replace(/^#.*$/gm, '') // strip markdown headers
|
|
442
|
+
.replace(/^[-*].*role:.*$/gim, '') // strip metadata lines
|
|
443
|
+
.replace(/^\s*$/gm, '') // strip blank lines
|
|
444
|
+
.trim();
|
|
445
|
+
if (briefContent.length < 200) {
|
|
446
|
+
console.warn(` Rotator: ${agent.name} handoff brief has only ${briefContent.length} chars of content — low-confidence handoff, skipping rotation`);
|
|
447
|
+
this.rotating.delete(agentId);
|
|
448
|
+
this.daemon.broadcast({
|
|
449
|
+
type: 'rotation:blocked',
|
|
450
|
+
agentId,
|
|
451
|
+
agentName: agent.name,
|
|
452
|
+
reason: 'low_confidence_handoff',
|
|
453
|
+
});
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
389
458
|
if (agent.role === 'planner' && !brief.includes('PLANNING ONLY')) {
|
|
390
459
|
brief = 'CRITICAL: You are a PLANNING ONLY agent. Do NOT implement code. Route all work to your team via .groove/recommended-team.json.\n\n' + brief;
|
|
391
460
|
}
|
|
@@ -423,9 +492,10 @@ export class Rotator extends EventEmitter {
|
|
|
423
492
|
const respawnModel = routingMode.mode === 'auto' ? 'auto' : agent.model;
|
|
424
493
|
|
|
425
494
|
// Remove old agent BEFORE spawning so registry.add() won't dedup the name
|
|
426
|
-
// (appending "-2", "-2-2", etc.).
|
|
495
|
+
// (appending "-2", "-2-2", etc.). Silent removal: the spawn's registry.add()
|
|
496
|
+
// will flush both removed+changed in one broadcast, preventing GUI flicker.
|
|
427
497
|
const savedConfig = { ...agent };
|
|
428
|
-
registry.remove(agentId);
|
|
498
|
+
registry.remove(agentId, { silent: true });
|
|
429
499
|
this.daemon.locks.release(agentId);
|
|
430
500
|
|
|
431
501
|
let newAgent;
|
|
@@ -443,7 +513,8 @@ export class Rotator extends EventEmitter {
|
|
|
443
513
|
isRotation: true,
|
|
444
514
|
});
|
|
445
515
|
} catch (spawnErr) {
|
|
446
|
-
// Spawn failed —
|
|
516
|
+
// Spawn failed — flush the silent removal so GUI sees it, then re-add.
|
|
517
|
+
registry.flushPendingRemovals();
|
|
447
518
|
registry.add({
|
|
448
519
|
role: savedConfig.role, scope: savedConfig.scope, provider: savedConfig.provider,
|
|
449
520
|
model: savedConfig.model, prompt: savedConfig.prompt, permission: savedConfig.permission,
|
|
@@ -428,6 +428,7 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
428
428
|
|
|
429
429
|
// Record user feedback so the journalist can include it in future agent context
|
|
430
430
|
if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
|
|
431
|
+
if (daemon.rotator) daemon.rotator.recordUserMessage(req.params.id);
|
|
431
432
|
|
|
432
433
|
// Agent loop path — send message directly to the running loop
|
|
433
434
|
const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
|
|
@@ -449,20 +450,26 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
449
450
|
if (daemon.processes.isRunning(req.params.id)) {
|
|
450
451
|
await daemon.processes.kill(req.params.id);
|
|
451
452
|
}
|
|
452
|
-
daemon.registry.remove(req.params.id);
|
|
453
|
+
daemon.registry.remove(req.params.id, { silent: true });
|
|
453
454
|
daemon.locks.release(req.params.id);
|
|
454
455
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
456
|
+
let newAgent;
|
|
457
|
+
try {
|
|
458
|
+
newAgent = await daemon.processes.spawn({
|
|
459
|
+
role: oldConfig.role,
|
|
460
|
+
scope: oldConfig.scope,
|
|
461
|
+
provider: oldConfig.provider,
|
|
462
|
+
model: oldConfig.model,
|
|
463
|
+
prompt: finalMessage,
|
|
464
|
+
permission: oldConfig.permission || 'full',
|
|
465
|
+
workingDir: oldConfig.workingDir,
|
|
466
|
+
name: oldConfig.name,
|
|
467
|
+
teamId: oldConfig.teamId,
|
|
468
|
+
});
|
|
469
|
+
} catch (spawnErr) {
|
|
470
|
+
daemon.registry.flushPendingRemovals();
|
|
471
|
+
throw spawnErr;
|
|
472
|
+
}
|
|
466
473
|
daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
|
|
467
474
|
return res.json(newAgent);
|
|
468
475
|
}
|
|
@@ -472,21 +479,27 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
472
479
|
// run one prompt per spawn and cannot resume sessions.
|
|
473
480
|
if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(req.params.id)) {
|
|
474
481
|
const oldConfig = { ...agent };
|
|
475
|
-
daemon.registry.remove(req.params.id);
|
|
482
|
+
daemon.registry.remove(req.params.id, { silent: true });
|
|
476
483
|
daemon.locks.release(req.params.id);
|
|
477
484
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
485
|
+
let newAgent;
|
|
486
|
+
try {
|
|
487
|
+
newAgent = await daemon.processes.spawn({
|
|
488
|
+
role: oldConfig.role,
|
|
489
|
+
scope: oldConfig.scope,
|
|
490
|
+
provider: oldConfig.provider,
|
|
491
|
+
model: oldConfig.model,
|
|
492
|
+
prompt: finalMessage,
|
|
493
|
+
introContext: oldConfig.introContext,
|
|
494
|
+
permission: oldConfig.permission || 'full',
|
|
495
|
+
workingDir: oldConfig.workingDir,
|
|
496
|
+
name: oldConfig.name,
|
|
497
|
+
teamId: oldConfig.teamId,
|
|
498
|
+
});
|
|
499
|
+
} catch (spawnErr) {
|
|
500
|
+
daemon.registry.flushPendingRemovals();
|
|
501
|
+
throw spawnErr;
|
|
502
|
+
}
|
|
490
503
|
daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
|
|
491
504
|
return res.json(newAgent);
|
|
492
505
|
}
|
|
@@ -479,6 +479,10 @@ export class TunnelManager {
|
|
|
479
479
|
// Remote is behind npm — upgrade
|
|
480
480
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: remoteVer, to: npmVer } });
|
|
481
481
|
|
|
482
|
+
const target = `${config.user}@${config.host}`;
|
|
483
|
+
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
484
|
+
const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
|
|
485
|
+
|
|
482
486
|
const installCmd = npmGlobalInstall(`groove-dev@${npmVer}`, config.user);
|
|
483
487
|
const cleanupCmd = 'rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true';
|
|
484
488
|
|
|
@@ -496,40 +500,43 @@ export class TunnelManager {
|
|
|
496
500
|
}
|
|
497
501
|
}
|
|
498
502
|
|
|
499
|
-
// Restart remote daemon
|
|
503
|
+
// Restart remote daemon — fire and forget the SSH, verify through the tunnel
|
|
500
504
|
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : '';
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
encoding: 'utf8', timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
507
|
-
});
|
|
505
|
+
try {
|
|
506
|
+
execFileSync('ssh', [...sshBase, sshCmd(`kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 1; ${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown`)], {
|
|
507
|
+
encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
508
|
+
});
|
|
509
|
+
} catch { /* SSH may close before nohup finishes — that's fine */ }
|
|
508
510
|
|
|
509
|
-
//
|
|
511
|
+
// Wait for daemon to come back up through the existing tunnel
|
|
512
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
510
513
|
let daemonVer = null;
|
|
511
|
-
for (let i = 0; i <
|
|
514
|
+
for (let i = 0; i < 8; i++) {
|
|
515
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
512
516
|
try {
|
|
513
|
-
const check = await fetch(`http://localhost:${localPort}/api/status`, {
|
|
514
|
-
signal: AbortSignal.timeout(3000),
|
|
515
|
-
});
|
|
517
|
+
const check = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(3000) });
|
|
516
518
|
if (check.ok) {
|
|
517
519
|
daemonVer = (await check.json()).version || null;
|
|
518
520
|
break;
|
|
519
521
|
}
|
|
520
|
-
} catch { /*
|
|
521
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
522
|
+
} catch { /* not up yet */ }
|
|
522
523
|
}
|
|
523
524
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
525
|
+
if (config.projectDir && daemonVer) {
|
|
526
|
+
try {
|
|
527
|
+
await fetch(`http://localhost:${localPort}/api/project-dir`, {
|
|
528
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
529
|
+
body: JSON.stringify({ path: config.projectDir }),
|
|
530
|
+
signal: AbortSignal.timeout(3000),
|
|
531
|
+
});
|
|
532
|
+
} catch { /* best effort */ }
|
|
529
533
|
}
|
|
530
534
|
|
|
535
|
+
const localVer = getLocalVersion();
|
|
536
|
+
this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: daemonVer || npmVer, match: (daemonVer || npmVer) === localVer } });
|
|
531
537
|
this.daemon.audit.log('tunnel.upgrade', { id, from: remoteVer, to: daemonVer || npmVer });
|
|
532
538
|
} catch (err) {
|
|
539
|
+
// Upgrade failed but tunnel may still work — check before reporting failure
|
|
533
540
|
try {
|
|
534
541
|
const verify = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
|
|
535
542
|
if (verify.ok) {
|
|
@@ -537,7 +544,7 @@ export class TunnelManager {
|
|
|
537
544
|
this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: getLocalVersion(), remoteVersion: verifyData.version, match: false } });
|
|
538
545
|
return;
|
|
539
546
|
}
|
|
540
|
-
} catch { /* tunnel
|
|
547
|
+
} catch { /* tunnel down */ }
|
|
541
548
|
this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: err.message } });
|
|
542
549
|
}
|
|
543
550
|
}
|
|
@@ -29,7 +29,7 @@ describe('Rotator', () => {
|
|
|
29
29
|
},
|
|
30
30
|
journalist: {
|
|
31
31
|
async generateHandoffBrief(agent, options = {}) {
|
|
32
|
-
return
|
|
32
|
+
return `## Handoff Brief\nAgent ${agent.name} was working on backend tasks in the project. The agent completed several file edits and ran tests successfully. Key context: the agent was modifying API routes and updating validation logic. Resume from where this agent left off and continue the implementation.`;
|
|
33
33
|
},
|
|
34
34
|
},
|
|
35
35
|
memory: {
|