groove-dev 0.27.181 → 0.27.182
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/CLAUDE.md +7 -0
- 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/scripts/repair-tokens.mjs +78 -0
- package/node_modules/@groove-dev/daemon/src/api.js +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +1 -0
- package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
- package/node_modules/@groove-dev/daemon/src/process.js +58 -33
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +9 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
- package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
- package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +52 -0
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
- package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/scripts/repair-tokens.mjs +78 -0
- package/packages/daemon/src/api.js +1 -1
- package/packages/daemon/src/index.js +1 -0
- package/packages/daemon/src/journalist.js +15 -8
- package/packages/daemon/src/process.js +58 -33
- package/packages/daemon/src/providers/claude-code.js +9 -1
- package/packages/daemon/src/rotator.js +62 -5
- package/packages/daemon/src/tokentracker.js +46 -6
- package/packages/gui/package.json +1 -1
|
@@ -174,13 +174,15 @@ describe('Journalist', () => {
|
|
|
174
174
|
const { daemon, grooveDir } = createMockDaemon();
|
|
175
175
|
const journalist = new Journalist(daemon);
|
|
176
176
|
|
|
177
|
-
// Create a mock log — filename must match agent.
|
|
177
|
+
// Create a mock log — filename must match agent.name ('backend-1'),
|
|
178
|
+
// mirroring the writer in process.js (logs are keyed by sanitized name
|
|
179
|
+
// so rotated agents keep appending to the same file)
|
|
178
180
|
const logLines = [
|
|
179
181
|
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Write', input: { file_path: 'src/api/auth.js' } }] } }),
|
|
180
182
|
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: 'src/api/users.js', old_string: 'old', new_string: 'new' } }] } }),
|
|
181
183
|
JSON.stringify({ type: 'user', message: { content: 'Add JWT middleware to the auth route' } }),
|
|
182
184
|
].join('\n');
|
|
183
|
-
writeFileSync(join(grooveDir, 'logs', '
|
|
185
|
+
writeFileSync(join(grooveDir, 'logs', 'backend-1.log'), logLines);
|
|
184
186
|
|
|
185
187
|
const agent = {
|
|
186
188
|
id: 'a1', name: 'backend-1', role: 'backend',
|
|
@@ -452,4 +452,135 @@ describe('Rotator', () => {
|
|
|
452
452
|
assert.equal(appendArgs.teamId, 'team-alpha');
|
|
453
453
|
assert.equal(appendArgs.role, 'backend');
|
|
454
454
|
});
|
|
455
|
+
|
|
456
|
+
it('should rotate idle Claude Code agent above the replay ceiling', async () => {
|
|
457
|
+
const agent = {
|
|
458
|
+
id: 'rc1', name: 'claude-chat', role: 'backend', status: 'running',
|
|
459
|
+
provider: 'claude-code', scope: [], model: null,
|
|
460
|
+
tokensUsed: 100_000, contextUsage: 0.55, workingDir: '/tmp',
|
|
461
|
+
lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(), // idle 5 min
|
|
462
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
463
|
+
};
|
|
464
|
+
mockDaemon.registry.agents = [agent];
|
|
465
|
+
|
|
466
|
+
await rotator.check();
|
|
467
|
+
|
|
468
|
+
const history = rotator.getHistory();
|
|
469
|
+
assert.equal(history.length, 1);
|
|
470
|
+
assert.equal(history[0].reason, 'replay_ceiling');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should NOT replay-ceiling rotate below the ceiling or during cooldown', async () => {
|
|
474
|
+
const agent = {
|
|
475
|
+
id: 'rc2', name: 'claude-chat-2', role: 'backend', status: 'running',
|
|
476
|
+
provider: 'claude-code', scope: [], model: null,
|
|
477
|
+
tokensUsed: 100_000, contextUsage: 0.45, workingDir: '/tmp',
|
|
478
|
+
lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
479
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
480
|
+
};
|
|
481
|
+
mockDaemon.registry.agents = [agent];
|
|
482
|
+
|
|
483
|
+
// Below ceiling — no rotation
|
|
484
|
+
await rotator.check();
|
|
485
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
486
|
+
|
|
487
|
+
// Above ceiling but on cooldown — still no rotation
|
|
488
|
+
agent.contextUsage = 0.60;
|
|
489
|
+
rotator.lastRotationTime.set('rc2', Date.now() - 60_000);
|
|
490
|
+
await rotator.check();
|
|
491
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should block auto-rotation of a chat agent when no conversation thread is extractable', async () => {
|
|
495
|
+
const agent = {
|
|
496
|
+
id: 'rc3', name: 'claude-chat-3', role: 'backend', status: 'running',
|
|
497
|
+
provider: 'claude-code', scope: [], model: null,
|
|
498
|
+
tokensUsed: 100_000, contextUsage: 0.70, workingDir: '/tmp',
|
|
499
|
+
lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
500
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
501
|
+
};
|
|
502
|
+
mockDaemon.registry.agents = [agent];
|
|
503
|
+
|
|
504
|
+
// Agent has chat history, but journalist can't extract the thread
|
|
505
|
+
rotator.recordUserMessage('rc3');
|
|
506
|
+
mockDaemon.journalist.buildConversationResumePrompt = () => null;
|
|
507
|
+
|
|
508
|
+
const result = await rotator.rotate('rc3', { reason: 'replay_ceiling' });
|
|
509
|
+
|
|
510
|
+
assert.equal(result, null);
|
|
511
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
512
|
+
const blocked = broadcasts.find((b) => b.type === 'rotation:blocked');
|
|
513
|
+
assert.ok(blocked, 'should broadcast rotation:blocked');
|
|
514
|
+
assert.equal(blocked.reason, 'no_conversation_thread');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should not let Instructions boilerplate defeat the low-confidence handoff guard', async () => {
|
|
518
|
+
const agent = {
|
|
519
|
+
id: 'rc4', name: 'claude-chat-4', role: 'backend', status: 'running',
|
|
520
|
+
provider: 'claude-code', scope: [], model: null,
|
|
521
|
+
tokensUsed: 100_000, contextUsage: 0.70, workingDir: '/tmp',
|
|
522
|
+
lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
523
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
524
|
+
};
|
|
525
|
+
mockDaemon.registry.agents = [agent];
|
|
526
|
+
|
|
527
|
+
// A content-free brief padded with the static Instructions block —
|
|
528
|
+
// this is exactly what generateHandoffBrief emits when logs are empty
|
|
529
|
+
mockDaemon.journalist.generateHandoffBrief = async () => [
|
|
530
|
+
'# Handoff Brief',
|
|
531
|
+
'- role: backend',
|
|
532
|
+
'## Instructions',
|
|
533
|
+
'',
|
|
534
|
+
'Continue and finish the in-progress task — deliver the output. Stay focused on that specific task only.',
|
|
535
|
+
'- Do NOT explore the codebase looking for other things to fix or improve',
|
|
536
|
+
'- Do NOT start new work outside the original task scope',
|
|
537
|
+
'- Do NOT act on TODO comments, code quality issues, or improvements you notice in passing',
|
|
538
|
+
'- If the task is already complete, report what was accomplished and STOP — await new instructions from the user',
|
|
539
|
+
].join('\n');
|
|
540
|
+
|
|
541
|
+
const result = await rotator.rotate('rc4', { reason: 'replay_ceiling' });
|
|
542
|
+
|
|
543
|
+
assert.equal(result, null);
|
|
544
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
545
|
+
const blocked = broadcasts.find((b) => b.type === 'rotation:blocked');
|
|
546
|
+
assert.ok(blocked, 'should broadcast rotation:blocked');
|
|
547
|
+
assert.equal(blocked.reason, 'low_confidence_handoff');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should force-rotate any provider on velocity ceiling', async () => {
|
|
551
|
+
const agent = {
|
|
552
|
+
id: 'v1', name: 'claude-runaway', role: 'backend', status: 'running',
|
|
553
|
+
provider: 'claude-code', scope: [], model: null,
|
|
554
|
+
tokensUsed: 400_000, contextUsage: 0.30, workingDir: '/tmp',
|
|
555
|
+
lastActivity: new Date(Date.now() - 5_000).toISOString(), // active
|
|
556
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
557
|
+
};
|
|
558
|
+
mockDaemon.registry.agents = [agent];
|
|
559
|
+
mockDaemon.tokens.getVelocity = () => 300_000; // above 250K/5min default
|
|
560
|
+
|
|
561
|
+
// Even on cooldown — velocity is a safety override
|
|
562
|
+
rotator.lastRotationTime.set('v1', Date.now() - 60_000);
|
|
563
|
+
|
|
564
|
+
await rotator.check();
|
|
565
|
+
|
|
566
|
+
const history = rotator.getHistory();
|
|
567
|
+
assert.equal(history.length, 1);
|
|
568
|
+
assert.equal(history[0].reason, 'velocity_ceiling');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should not velocity-rotate under the ceiling', async () => {
|
|
572
|
+
const agent = {
|
|
573
|
+
id: 'v2', name: 'claude-normal', role: 'backend', status: 'running',
|
|
574
|
+
provider: 'claude-code', scope: [], model: null,
|
|
575
|
+
tokensUsed: 50_000, contextUsage: 0.30, workingDir: '/tmp',
|
|
576
|
+
lastActivity: new Date(Date.now() - 5_000).toISOString(),
|
|
577
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
578
|
+
};
|
|
579
|
+
mockDaemon.registry.agents = [agent];
|
|
580
|
+
mockDaemon.tokens.getVelocity = () => 80_000;
|
|
581
|
+
|
|
582
|
+
await rotator.check();
|
|
583
|
+
|
|
584
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
585
|
+
});
|
|
455
586
|
});
|
|
@@ -61,6 +61,10 @@ describe('TokenTracker', () => {
|
|
|
61
61
|
tracker.record('agent-1', 100);
|
|
62
62
|
tracker.record('agent-2', 200);
|
|
63
63
|
|
|
64
|
+
// record() debounces disk writes (hot path); flush forces the pending save,
|
|
65
|
+
// mirroring what the daemon does on shutdown.
|
|
66
|
+
tracker.flush();
|
|
67
|
+
|
|
64
68
|
const tracker2 = new TokenTracker(tmpDir);
|
|
65
69
|
assert.equal(tracker2.getAgent('agent-1').total, 100);
|
|
66
70
|
assert.equal(tracker2.getAgent('agent-2').total, 200);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.182",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// One-off ledger repair for ~/.groove/tokens.json (2026-07 efficiency audit).
|
|
5
|
+
//
|
|
6
|
+
// Entry c3789b6f recorded $20,536.57 from a duplicate-ingestion loop (4,631
|
|
7
|
+
// session records in 380s, payloads duplicated up to 10×). The ingestion bug
|
|
8
|
+
// is fixed in claude-code.js, but the poisoned data still dominates every cost
|
|
9
|
+
// dashboard: recorded all-time spend ~$24K vs ~$3.5K real. This script:
|
|
10
|
+
// 1. Sets the poisoned entry's totalCostUsd to its audited real value (~$325)
|
|
11
|
+
// 2. Truncates every entry's sessions array to the last 500 records
|
|
12
|
+
// (matches SESSION_CAP in tokentracker.js; aggregates are untouched)
|
|
13
|
+
// 3. Writes a timestamped .bak of the original file first
|
|
14
|
+
//
|
|
15
|
+
// MUST run with the daemon STOPPED — the daemon holds the ledger in memory and
|
|
16
|
+
// rewrites the file, so a live edit gets clobbered. The script checks and aborts.
|
|
17
|
+
//
|
|
18
|
+
// Usage: node repair-tokens.mjs [path-to-tokens.json] [--entry c3789b6f] [--cost 325]
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'fs';
|
|
21
|
+
import { resolve } from 'path';
|
|
22
|
+
import { homedir } from 'os';
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const flag = (name, dflt) => {
|
|
26
|
+
const i = args.indexOf(`--${name}`);
|
|
27
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : dflt;
|
|
28
|
+
};
|
|
29
|
+
const tokensPath = args[0] && !args[0].startsWith('--')
|
|
30
|
+
? resolve(args[0])
|
|
31
|
+
: resolve(homedir(), '.groove', 'tokens.json');
|
|
32
|
+
const entryId = flag('entry', 'c3789b6f');
|
|
33
|
+
const correctedCost = Number(flag('cost', '325'));
|
|
34
|
+
const SESSION_CAP = 500;
|
|
35
|
+
|
|
36
|
+
// Refuse to edit under a live daemon
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch('http://127.0.0.1:31415/api/health', { signal: AbortSignal.timeout(1500) });
|
|
39
|
+
if (res.ok) {
|
|
40
|
+
console.error('ABORT: GROOVE daemon is running on :31415. Stop it first (groove stop) — it rewrites tokens.json and will clobber this repair.');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
} catch { /* no daemon — safe to proceed */ }
|
|
44
|
+
|
|
45
|
+
if (!existsSync(tokensPath)) {
|
|
46
|
+
console.error(`ABORT: ${tokensPath} not found. Pass the path explicitly: node repair-tokens.mjs /path/to/tokens.json`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const backupPath = `${tokensPath}.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`;
|
|
51
|
+
copyFileSync(tokensPath, backupPath);
|
|
52
|
+
console.log(`Backup written: ${backupPath}`);
|
|
53
|
+
|
|
54
|
+
const data = JSON.parse(readFileSync(tokensPath, 'utf8'));
|
|
55
|
+
const usage = data.usage || data;
|
|
56
|
+
|
|
57
|
+
const target = usage[entryId];
|
|
58
|
+
if (target) {
|
|
59
|
+
const before = target.totalCostUsd;
|
|
60
|
+
target.totalCostUsd = correctedCost;
|
|
61
|
+
target.note = `totalCostUsd corrected ${before} -> ${correctedCost} on ${new Date().toISOString().slice(0, 10)} (duplicate-ingestion inflation, see 2026-07 efficiency audit)`;
|
|
62
|
+
console.log(`Entry ${entryId}: totalCostUsd ${before} -> ${correctedCost}`);
|
|
63
|
+
} else {
|
|
64
|
+
console.warn(`Entry ${entryId} not found — skipping cost correction.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let trimmed = 0;
|
|
68
|
+
for (const entry of Object.values(usage)) {
|
|
69
|
+
if (Array.isArray(entry?.sessions) && entry.sessions.length > SESSION_CAP) {
|
|
70
|
+
trimmed += entry.sessions.length - SESSION_CAP;
|
|
71
|
+
entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
console.log(`Trimmed ${trimmed} session records across all entries (cap ${SESSION_CAP}).`);
|
|
75
|
+
|
|
76
|
+
writeFileSync(tokensPath, JSON.stringify(data, null, 2));
|
|
77
|
+
const sizeMb = (Buffer.byteLength(JSON.stringify(data)) / 1024 / 1024).toFixed(1);
|
|
78
|
+
console.log(`Repaired ledger written: ${tokensPath} (${sizeMb}MB)`);
|
|
@@ -1438,7 +1438,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1438
1438
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
1439
1439
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
1440
1440
|
'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
|
|
1441
|
-
'dataSharingDismissed',
|
|
1441
|
+
'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
|
|
1442
1442
|
];
|
|
1443
1443
|
for (const key of Object.keys(req.body)) {
|
|
1444
1444
|
if (!ALLOWED_KEYS.includes(key)) {
|
|
@@ -836,6 +836,7 @@ export class Daemon {
|
|
|
836
836
|
// Persist state before shutdown
|
|
837
837
|
this.state.set('agents', this.registry.getAll());
|
|
838
838
|
await this.state.save();
|
|
839
|
+
try { this.tokens.flush(); } catch { /* best-effort */ }
|
|
839
840
|
|
|
840
841
|
// Stop background services
|
|
841
842
|
await this.gateways.stop();
|
|
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs
|
|
|
5
5
|
import { resolve } from 'path';
|
|
6
6
|
import { execFile, spawn as cpSpawn } from 'child_process';
|
|
7
7
|
import { getProvider, getInstalledProviders, resolveProviderCommand } from './providers/index.js';
|
|
8
|
+
import { agentLogPath } from './process.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
|
|
10
11
|
const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
|
|
@@ -168,7 +169,7 @@ export class Journalist {
|
|
|
168
169
|
|
|
169
170
|
hasNewActivity(agents) {
|
|
170
171
|
for (const agent of agents) {
|
|
171
|
-
const logPath =
|
|
172
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
172
173
|
if (!existsSync(logPath)) continue;
|
|
173
174
|
try {
|
|
174
175
|
const size = statSync(logPath).size;
|
|
@@ -182,7 +183,7 @@ export class Journalist {
|
|
|
182
183
|
const result = {};
|
|
183
184
|
|
|
184
185
|
for (const agent of agents) {
|
|
185
|
-
const logPath =
|
|
186
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
186
187
|
if (!existsSync(logPath)) {
|
|
187
188
|
result[agent.id] = { agent, entries: [], explorationEntries: [] };
|
|
188
189
|
continue;
|
|
@@ -574,9 +575,15 @@ export class Journalist {
|
|
|
574
575
|
const provider = getProvider(providerId);
|
|
575
576
|
if (!provider) continue;
|
|
576
577
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
578
|
+
// Digests are light-tier work (Haiku-shaped): default internal overhead to
|
|
579
|
+
// the cheapest tier, escalate via config only on evidence of quality issues.
|
|
580
|
+
// On Claude, medium→light cuts the journalist's marginal cost ~90%.
|
|
581
|
+
const models = provider.constructor.models || [];
|
|
582
|
+
const preferredTier = this.daemon.config?.journalistModelTier || 'light';
|
|
583
|
+
const selectedModel = models.find((m) => m.tier === preferredTier)
|
|
584
|
+
|| models.find((m) => m.tier === 'light')
|
|
585
|
+
|| models.find((m) => m.tier === 'medium')
|
|
586
|
+
|| models[0];
|
|
580
587
|
const modelId = selectedModel?.id || null;
|
|
581
588
|
|
|
582
589
|
// Try CLI headless command first
|
|
@@ -1055,7 +1062,7 @@ export class Journalist {
|
|
|
1055
1062
|
* Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
|
|
1056
1063
|
*/
|
|
1057
1064
|
extractConversationThread(agent, { maxChars = 60000 } = {}) {
|
|
1058
|
-
const logPath =
|
|
1065
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1059
1066
|
if (!existsSync(logPath)) return null;
|
|
1060
1067
|
|
|
1061
1068
|
let content;
|
|
@@ -1297,7 +1304,7 @@ export class Journalist {
|
|
|
1297
1304
|
* Used by the Introducer to tell new agents what their teammates built.
|
|
1298
1305
|
*/
|
|
1299
1306
|
getAgentFiles(agent) {
|
|
1300
|
-
const logPath =
|
|
1307
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1301
1308
|
if (!existsSync(logPath)) return [];
|
|
1302
1309
|
|
|
1303
1310
|
try {
|
|
@@ -1335,7 +1342,7 @@ export class Journalist {
|
|
|
1335
1342
|
* Used to capture planner conclusions, build summaries, etc.
|
|
1336
1343
|
*/
|
|
1337
1344
|
getAgentResult(agent) {
|
|
1338
|
-
const logPath =
|
|
1345
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1339
1346
|
if (!existsSync(logPath)) return '';
|
|
1340
1347
|
|
|
1341
1348
|
try {
|
|
@@ -324,6 +324,15 @@ function sanitizeFilename(name) {
|
|
|
324
324
|
return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
// Agent log files are keyed by sanitized agent NAME, not id — a rotated agent
|
|
328
|
+
// keeps its name and appends to the same file, preserving conversation history
|
|
329
|
+
// across rotations. Every reader must resolve paths through this helper: an
|
|
330
|
+
// id-based path silently misses the file and downstream consumers (handoff
|
|
331
|
+
// briefs, conversation resume, synthesis) fall back to empty context.
|
|
332
|
+
export function agentLogPath(grooveDir, agent) {
|
|
333
|
+
return resolve(grooveDir, 'logs', `${sanitizeFilename(agent.name)}.log`);
|
|
334
|
+
}
|
|
335
|
+
|
|
327
336
|
// Apply Claude Code billing mode to a spawn env, in place.
|
|
328
337
|
// The `claude` CLI bills to usage credits when ANTHROPIC_API_KEY is present and
|
|
329
338
|
// to the OAuth subscription otherwise. 'subscription' (default) strips any
|
|
@@ -1146,7 +1155,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1146
1155
|
// Set up log capture (shared between CLI and agent loop paths)
|
|
1147
1156
|
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
1148
1157
|
mkdirSync(logDir, { recursive: true });
|
|
1149
|
-
const logPath =
|
|
1158
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1150
1159
|
const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
|
|
1151
1160
|
|
|
1152
1161
|
// Inject API key from credential store for agent-loop providers
|
|
@@ -1567,38 +1576,54 @@ For normal file edits within your scope, proceed without review.
|
|
|
1567
1576
|
|
|
1568
1577
|
// Session result data (cost, duration, turns)
|
|
1569
1578
|
if (output.type === 'result') {
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
//
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
if (
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1579
|
+
// Did this result represent a turn that actually ran? CLI >= 2.1.212
|
|
1580
|
+
// emits a 0-turn no-op `result` during --resume reconciliation, before
|
|
1581
|
+
// the real turn starts. Both it and an aborted turn report
|
|
1582
|
+
// duration_api_ms === 0 — the model was never called. Providers that
|
|
1583
|
+
// don't report an API duration (codex, gemini, ollama) are unaffected.
|
|
1584
|
+
const reachedModel = output.apiDurationMs === undefined || output.apiDurationMs > 0;
|
|
1585
|
+
|
|
1586
|
+
// Turn aborted before the model was called — re-deliver the message.
|
|
1587
|
+
// Deliberately does NOT return: session id persistence and the GUI
|
|
1588
|
+
// broadcast below still need to run, and the retry is scheduled async.
|
|
1589
|
+
if (!reachedModel && output.isError) {
|
|
1590
|
+
this._retryDroppedTurn(agentId, agent, output);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// Everything below is completion bookkeeping and must only run for a
|
|
1594
|
+
// turn that actually executed. Running it for the resume handshake is
|
|
1595
|
+
// what killed the process mid-turn.
|
|
1596
|
+
if (reachedModel) {
|
|
1597
|
+
// The user's message is safely delivered.
|
|
1598
|
+
this._pendingUserMessage.delete(agentId);
|
|
1599
|
+
|
|
1600
|
+
tokens.recordResult(agentId, {
|
|
1601
|
+
costUsd: output.cost, durationMs: output.duration, turns: output.turns,
|
|
1602
|
+
});
|
|
1603
|
+
if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
|
|
1604
|
+
if (output.duration) updates.durationMs = output.duration;
|
|
1605
|
+
if (output.turns) updates.turns = output.turns;
|
|
1606
|
+
|
|
1607
|
+
// Claude Code sometimes hangs after emitting the result event — the
|
|
1608
|
+
// process stays alive instead of exiting. Record that the result
|
|
1609
|
+
// arrived so exit handlers know this was a successful completion even
|
|
1610
|
+
// if we have to SIGTERM the process. After a 5s grace period, force-
|
|
1611
|
+
// kill any process that hasn't exited on its own.
|
|
1612
|
+
//
|
|
1613
|
+
// MUST stay behind the reachedModel guard. CLI >= 2.1.212 emits a
|
|
1614
|
+
// 0-turn no-op result ~180ms into --resume; arming this on that result
|
|
1615
|
+
// SIGTERMs the process ~5.2s later, mid-turn, before a token is
|
|
1616
|
+
// produced (fable TTFT observed at 34s). The CLI records the signal as
|
|
1617
|
+
// "[Request interrupted by user]" and the user's message is destroyed.
|
|
1618
|
+
this._resultReceived.add(agentId);
|
|
1619
|
+
const handle = this.handles.get(agentId);
|
|
1620
|
+
if (handle?.proc && typeof handle.proc.kill === 'function') {
|
|
1621
|
+
setTimeout(() => {
|
|
1622
|
+
if (this.handles.has(agentId) && this._resultReceived.has(agentId)) {
|
|
1623
|
+
try { handle.proc.kill('SIGTERM'); } catch {}
|
|
1624
|
+
}
|
|
1625
|
+
}, 5_000);
|
|
1626
|
+
}
|
|
1602
1627
|
}
|
|
1603
1628
|
}
|
|
1604
1629
|
|
|
@@ -275,7 +275,15 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
275
275
|
|
|
276
276
|
// Merge events: prefer content-bearing events (activity/result) over usage/session.
|
|
277
277
|
// Accumulate token counts across all events in this chunk.
|
|
278
|
-
|
|
278
|
+
// Prefer an error result over a success one when a single chunk carries
|
|
279
|
+
// both. During --resume the CLI emits a 0-turn no-op success result
|
|
280
|
+
// immediately before the real turn's result; taking the first would hide a
|
|
281
|
+
// subsequent abort and the failure would look like a clean completion.
|
|
282
|
+
const resultEvents = events.filter((e) => e.type === 'result');
|
|
283
|
+
let content = resultEvents.find((e) => e.isError)
|
|
284
|
+
|| resultEvents[0]
|
|
285
|
+
|| events.find((e) => e.type === 'activity')
|
|
286
|
+
|| events[events.length - 1];
|
|
279
287
|
const merged = { ...content };
|
|
280
288
|
|
|
281
289
|
let totalTokens = 0;
|
|
@@ -18,6 +18,9 @@ const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
|
|
|
18
18
|
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
|
|
19
19
|
const QUALITY_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes for quality degradation rotations
|
|
20
20
|
const TOKEN_CEILING = 5_000_000; // 5M tokens per agent (non-self-managing only)
|
|
21
|
+
const REPLAY_CEILING = 0.50; // Self-managing providers: rotate idle agents above this context usage
|
|
22
|
+
const VELOCITY_CEILING = 250_000; // New (non-cache) tokens per window before force rotation
|
|
23
|
+
const VELOCITY_WINDOW_MS = 5 * 60_000;
|
|
21
24
|
const ROLE_MULTIPLIERS = {
|
|
22
25
|
planner: 2,
|
|
23
26
|
fullstack: 4,
|
|
@@ -201,13 +204,34 @@ export class Rotator extends EventEmitter {
|
|
|
201
204
|
const singleTask = providerInstance?.constructor?.singleTask ?? false;
|
|
202
205
|
if (singleTask) continue;
|
|
203
206
|
|
|
204
|
-
// Skip agents idle for over 60s — no point scoring them every 15s
|
|
205
|
-
const idleMs = this._idleMs(agent);
|
|
206
|
-
if (idleMs > 60_000 && agent.contextUsage < HARD_CEILING) continue;
|
|
207
|
-
|
|
208
207
|
// Determine if provider manages its own context (e.g. Claude Code compacts internally)
|
|
209
208
|
const selfManagesContext = providerInstance?.constructor?.managesOwnContext ?? false;
|
|
210
209
|
|
|
210
|
+
// Velocity safety — new-token burn rate, all provider types. tokensUsed
|
|
211
|
+
// excludes cache reads, so this measures real generation. Caps worst-case
|
|
212
|
+
// runaway burn; bypasses cooldown (a runaway must be stopped, not waited out).
|
|
213
|
+
const velCeiling = this.daemon.config?.velocityCeiling ?? VELOCITY_CEILING;
|
|
214
|
+
const velocity = this.daemon.tokens?.getVelocity?.(agent.id, VELOCITY_WINDOW_MS) ?? 0;
|
|
215
|
+
if (velCeiling > 0 && velocity >= velCeiling) {
|
|
216
|
+
console.warn(` Rotator: ${agent.name} burned ${velCeiling.toLocaleString()}+ new tokens in 5min — FORCE rotating (velocity)`);
|
|
217
|
+
await this.rotate(agent.id, { reason: 'velocity_ceiling' });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Replay ceiling — self-managing providers only. Internal compaction keeps
|
|
222
|
+
// sessions functional but lets context ride at 60-85% indefinitely, and every
|
|
223
|
+
// subsequent message replays that much context on EVERY API call in the agent
|
|
224
|
+
// loop. A handoff-brief rotation costs ~10K tokens once; riding high costs
|
|
225
|
+
// 150-300K per call. Computed here because the idle-skip guard below must
|
|
226
|
+
// not hide the exact agents this targets (idle, high-context).
|
|
227
|
+
const replayCeiling = this.daemon.config?.replayCeiling ?? REPLAY_CEILING;
|
|
228
|
+
const overReplayCeiling = selfManagesContext && replayCeiling > 0
|
|
229
|
+
&& agent.contextUsage >= replayCeiling;
|
|
230
|
+
|
|
231
|
+
// Skip agents idle for over 60s — no point scoring them every 15s
|
|
232
|
+
const idleMs = this._idleMs(agent);
|
|
233
|
+
if (idleMs > 60_000 && agent.contextUsage < HARD_CEILING && !overReplayCeiling) continue;
|
|
234
|
+
|
|
211
235
|
if (!selfManagesContext) {
|
|
212
236
|
// Non-Claude: threshold + ceiling + quality rotation
|
|
213
237
|
// These providers fill up linearly and degrade without external rotation
|
|
@@ -260,6 +284,13 @@ export class Rotator extends EventEmitter {
|
|
|
260
284
|
continue;
|
|
261
285
|
}
|
|
262
286
|
}
|
|
287
|
+
} else if (overReplayCeiling && !this._isOnCooldown(agent.id) && idleMs > 10_000) {
|
|
288
|
+
// Rotate while idle only — never mid-task — and respect the cooldown.
|
|
289
|
+
// Continuity comes from the conversation-thread resume; rotate() blocks
|
|
290
|
+
// the rotation entirely if the thread can't be extracted for a chat agent.
|
|
291
|
+
console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — rotating (replay ceiling)`);
|
|
292
|
+
await this.rotate(agent.id, { reason: 'replay_ceiling' });
|
|
293
|
+
continue;
|
|
263
294
|
}
|
|
264
295
|
|
|
265
296
|
// --- Change 4: Truncation-triggered rotation ---
|
|
@@ -409,6 +440,7 @@ export class Rotator extends EventEmitter {
|
|
|
409
440
|
// empty or the thread is too short to be useful.
|
|
410
441
|
let brief;
|
|
411
442
|
let usedConversationThread = false;
|
|
443
|
+
const isAutoRotation = options.reason && options.reason !== 'manual';
|
|
412
444
|
if (typeof journalist.buildConversationResumePrompt === 'function') {
|
|
413
445
|
const conversationPrompt = journalist.buildConversationResumePrompt(
|
|
414
446
|
agent,
|
|
@@ -421,6 +453,22 @@ export class Rotator extends EventEmitter {
|
|
|
421
453
|
}
|
|
422
454
|
}
|
|
423
455
|
if (!usedConversationThread) {
|
|
456
|
+
// Chat-continuity protection: an agent with a live user conversation must
|
|
457
|
+
// carry that dialogue into the fresh session. If the thread can't be
|
|
458
|
+
// extracted, a boilerplate brief would wipe the agent's memory of the
|
|
459
|
+
// conversation — skip the auto-rotation instead (context stays intact,
|
|
460
|
+
// worst case is status quo). Manual rotations proceed; the user accepts the risk.
|
|
461
|
+
if (isAutoRotation && this.userMessageTimes.has(agentId)) {
|
|
462
|
+
console.warn(` Rotator: ${agent.name} has chat history but no extractable conversation thread — skipping rotation`);
|
|
463
|
+
this.rotating.delete(agentId);
|
|
464
|
+
this.daemon.broadcast({
|
|
465
|
+
type: 'rotation:blocked',
|
|
466
|
+
agentId,
|
|
467
|
+
agentName: agent.name,
|
|
468
|
+
reason: 'no_conversation_thread',
|
|
469
|
+
});
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
424
472
|
brief = await journalist.generateHandoffBrief(agent, {
|
|
425
473
|
reason: options.reason,
|
|
426
474
|
qualityScore: options.qualityScore,
|
|
@@ -435,11 +483,14 @@ export class Rotator extends EventEmitter {
|
|
|
435
483
|
// with too little meaningful content, the new agent will arrive blind.
|
|
436
484
|
// Block it rather than spawning an agent that can't continue the work.
|
|
437
485
|
// Manual rotations (user-requested) are never blocked — the user accepts the risk.
|
|
438
|
-
const isAutoRotation = options.reason && options.reason !== 'manual';
|
|
439
486
|
if (isAutoRotation && !usedConversationThread) {
|
|
440
487
|
const briefContent = brief
|
|
441
488
|
.replace(/^#.*$/gm, '') // strip markdown headers
|
|
442
489
|
.replace(/^[-*].*role:.*$/gim, '') // strip metadata lines
|
|
490
|
+
// Strip the static Instructions boilerplate generateHandoffBrief always
|
|
491
|
+
// appends (~400 chars) — padding must not defeat the content threshold
|
|
492
|
+
.replace(/^- (Do NOT|If the task).*$/gm, '')
|
|
493
|
+
.replace(/^Continue and finish the in-progress task.*$/gm, '')
|
|
443
494
|
.replace(/^\s*$/gm, '') // strip blank lines
|
|
444
495
|
.trim();
|
|
445
496
|
if (briefContent.length < 200) {
|
|
@@ -716,6 +767,8 @@ export class Rotator extends EventEmitter {
|
|
|
716
767
|
const hardCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'hard_ceiling').length;
|
|
717
768
|
const tokenCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'token_ceiling').length;
|
|
718
769
|
const estimatedCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'estimated_context_ceiling').length;
|
|
770
|
+
const replayCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'replay_ceiling').length;
|
|
771
|
+
const velocityCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'velocity_ceiling').length;
|
|
719
772
|
return {
|
|
720
773
|
enabled: this.enabled,
|
|
721
774
|
totalRotations,
|
|
@@ -726,6 +779,8 @@ export class Rotator extends EventEmitter {
|
|
|
726
779
|
hardCeilingRotations,
|
|
727
780
|
tokenCeilingRotations,
|
|
728
781
|
estimatedCeilingRotations,
|
|
782
|
+
replayCeilingRotations,
|
|
783
|
+
velocityCeilingRotations,
|
|
729
784
|
rotating: Array.from(this.rotating),
|
|
730
785
|
liveScores: this.liveScores,
|
|
731
786
|
scoreHistory: this.scoreHistory,
|
|
@@ -734,6 +789,8 @@ export class Rotator extends EventEmitter {
|
|
|
734
789
|
qualityThreshold: QUALITY_THRESHOLD,
|
|
735
790
|
cooldownMs: COOLDOWN_MS,
|
|
736
791
|
tokenCeiling: TOKEN_CEILING,
|
|
792
|
+
replayCeiling: this.daemon.config?.replayCeiling ?? REPLAY_CEILING,
|
|
793
|
+
velocityCeiling: this.daemon.config?.velocityCeiling ?? VELOCITY_CEILING,
|
|
737
794
|
roleMultipliers: ROLE_MULTIPLIERS,
|
|
738
795
|
};
|
|
739
796
|
}
|