groove-dev 0.27.180 → 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 +134 -21
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +16 -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 +128 -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 +134 -21
- package/packages/daemon/src/providers/claude-code.js +16 -1
- package/packages/daemon/src/rotator.js +62 -5
- package/packages/daemon/src/tokentracker.js +46 -6
- package/packages/gui/package.json +1 -1
|
@@ -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
|
|
@@ -363,6 +372,14 @@ export class ProcessManager {
|
|
|
363
372
|
this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
|
|
364
373
|
this._exitHandled = new Set();
|
|
365
374
|
this._resultReceived = new Set();
|
|
375
|
+
// In-flight user messages, kept until a turn actually completes so a
|
|
376
|
+
// dropped turn can be re-issued instead of losing the user's message.
|
|
377
|
+
// agentId -> { message, attempts }
|
|
378
|
+
this._pendingUserMessage = new Map();
|
|
379
|
+
// Retry count carried across the agent-id change that resume() performs.
|
|
380
|
+
// Kept separate so teardown of the old agent can't reset it to 0 and
|
|
381
|
+
// turn a bounded retry into an infinite respawn loop. oldAgentId -> attempts
|
|
382
|
+
this._retryAttemptCarry = new Map();
|
|
366
383
|
this._truncationFlagged = new Set(); // agentIds that have had any truncation in their session
|
|
367
384
|
this._lastAssistantBlocks = new Map(); // agentId -> last assistant content blocks (for abandoned tool_use detection)
|
|
368
385
|
this._previousCacheReadTokens = new Map(); // agentId -> previous turn's cacheReadTokens
|
|
@@ -1138,7 +1155,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1138
1155
|
// Set up log capture (shared between CLI and agent loop paths)
|
|
1139
1156
|
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
1140
1157
|
mkdirSync(logDir, { recursive: true });
|
|
1141
|
-
const logPath =
|
|
1158
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1142
1159
|
const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
|
|
1143
1160
|
|
|
1144
1161
|
// Inject API key from credential store for agent-loop providers
|
|
@@ -1559,26 +1576,54 @@ For normal file edits within your scope, proceed without review.
|
|
|
1559
1576
|
|
|
1560
1577
|
// Session result data (cost, duration, turns)
|
|
1561
1578
|
if (output.type === 'result') {
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
+
}
|
|
1582
1627
|
}
|
|
1583
1628
|
}
|
|
1584
1629
|
|
|
@@ -2510,6 +2555,59 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2510
2555
|
* spawns fresh with the full conversation thread instead of --resume.
|
|
2511
2556
|
* This avoids degraded context from internal compaction.
|
|
2512
2557
|
*/
|
|
2558
|
+
/**
|
|
2559
|
+
* Re-issue a user message whose turn was aborted before reaching the model.
|
|
2560
|
+
*
|
|
2561
|
+
* Returns true if a retry was scheduled (caller should skip normal result
|
|
2562
|
+
* handling), false if the turn should be treated as a real result.
|
|
2563
|
+
*/
|
|
2564
|
+
_retryDroppedTurn(agentId, agent, output) {
|
|
2565
|
+
const pending = this._pendingUserMessage.get(agentId);
|
|
2566
|
+
if (!pending) return false; // nothing to re-issue — let it fall through
|
|
2567
|
+
|
|
2568
|
+
const MAX_RETRIES = 2;
|
|
2569
|
+
if (pending.attempts >= MAX_RETRIES) {
|
|
2570
|
+
this._pendingUserMessage.delete(agentId);
|
|
2571
|
+
this.daemon.broadcast({
|
|
2572
|
+
type: 'agent:output',
|
|
2573
|
+
agentId,
|
|
2574
|
+
data: {
|
|
2575
|
+
type: 'activity',
|
|
2576
|
+
subtype: 'error',
|
|
2577
|
+
data: `Message could not be delivered after ${MAX_RETRIES + 1} attempts `
|
|
2578
|
+
+ `(${output.terminalReason || output.subtype || 'turn aborted'}). Please re-send.`,
|
|
2579
|
+
},
|
|
2580
|
+
});
|
|
2581
|
+
return false;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
pending.attempts += 1;
|
|
2585
|
+
this._retryAttemptCarry.set(agentId, pending.attempts);
|
|
2586
|
+
this.daemon.broadcast({
|
|
2587
|
+
type: 'agent:output',
|
|
2588
|
+
agentId,
|
|
2589
|
+
data: {
|
|
2590
|
+
type: 'activity',
|
|
2591
|
+
subtype: 'info',
|
|
2592
|
+
data: `Turn aborted before reaching the model — retrying (${pending.attempts}/${MAX_RETRIES})…`,
|
|
2593
|
+
},
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
// Let the aborted process finish tearing down before re-spawning; resume()
|
|
2597
|
+
// kills the current handle and re-registers under a new agent id.
|
|
2598
|
+
setTimeout(() => {
|
|
2599
|
+
this.resume(agentId, pending.message).catch((err) => {
|
|
2600
|
+
this.daemon.broadcast({
|
|
2601
|
+
type: 'agent:output',
|
|
2602
|
+
agentId,
|
|
2603
|
+
data: { type: 'activity', subtype: 'error', data: `Retry failed: ${err.message}` },
|
|
2604
|
+
});
|
|
2605
|
+
});
|
|
2606
|
+
}, 500);
|
|
2607
|
+
|
|
2608
|
+
return true;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2513
2611
|
async resume(agentId, message) {
|
|
2514
2612
|
const { registry, locks } = this.daemon;
|
|
2515
2613
|
const agent = registry.get(agentId);
|
|
@@ -2577,8 +2675,19 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2577
2675
|
workingDir: config.workingDir || this.daemon.config?.defaultWorkingDir || undefined,
|
|
2578
2676
|
name: config.name,
|
|
2579
2677
|
teamId: config.teamId,
|
|
2678
|
+
authMode: config.authMode,
|
|
2580
2679
|
});
|
|
2581
2680
|
|
|
2681
|
+
// Hold the message until a turn actually completes, so a turn aborted
|
|
2682
|
+
// before reaching the model can be re-issued rather than lost. Carries the
|
|
2683
|
+
// attempt count forward from the agent id we're replacing.
|
|
2684
|
+
if (message) {
|
|
2685
|
+
const carried = this._retryAttemptCarry.get(agentId);
|
|
2686
|
+
this._retryAttemptCarry.delete(agentId);
|
|
2687
|
+
this._pendingUserMessage.delete(agentId);
|
|
2688
|
+
this._pendingUserMessage.set(newAgent.id, { message, attempts: carried || 0 });
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2582
2691
|
// Carry cumulative tokens
|
|
2583
2692
|
if (config.tokensUsed > 0) {
|
|
2584
2693
|
registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
|
|
@@ -2959,6 +3068,10 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2959
3068
|
if (throttle?.timer) clearTimeout(throttle.timer);
|
|
2960
3069
|
this._streamThrottle.delete(agentId);
|
|
2961
3070
|
this.pendingMessages.delete(agentId);
|
|
3071
|
+
// Safe during a retry: resume() re-registers the message under the new
|
|
3072
|
+
// agent id from its own `message` argument, and the attempt count lives in
|
|
3073
|
+
// _retryAttemptCarry (which kill() must not touch).
|
|
3074
|
+
this._pendingUserMessage.delete(agentId);
|
|
2962
3075
|
|
|
2963
3076
|
// Unregister ambassador if this agent was one
|
|
2964
3077
|
const agent = this.daemon.registry.get(agentId);
|
|
@@ -257,6 +257,13 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
257
257
|
cost: data.total_cost_usd,
|
|
258
258
|
duration: data.duration_ms,
|
|
259
259
|
turns: data.num_turns,
|
|
260
|
+
// Error/abort signals. Without these an `error_during_execution`
|
|
261
|
+
// result looks identical to a successful one and the user's
|
|
262
|
+
// message is silently dropped.
|
|
263
|
+
subtype: data.subtype,
|
|
264
|
+
isError: data.is_error === true || data.subtype === 'error_during_execution',
|
|
265
|
+
apiDurationMs: data.duration_api_ms,
|
|
266
|
+
terminalReason: data.terminal_reason,
|
|
260
267
|
});
|
|
261
268
|
}
|
|
262
269
|
} catch {
|
|
@@ -268,7 +275,15 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
268
275
|
|
|
269
276
|
// Merge events: prefer content-bearing events (activity/result) over usage/session.
|
|
270
277
|
// Accumulate token counts across all events in this chunk.
|
|
271
|
-
|
|
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];
|
|
272
287
|
const merged = { ...content };
|
|
273
288
|
|
|
274
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
|
}
|
|
@@ -12,6 +12,14 @@ const COLD_START_PER_FILE = 15;
|
|
|
12
12
|
const COLD_START_PER_DIR = 40;
|
|
13
13
|
// Estimated tokens wasted per file conflict (agent discovers, backs off, retries)
|
|
14
14
|
const CONFLICT_OVERHEAD = 500;
|
|
15
|
+
// Max per-agent session records kept. Aggregates are cumulative and unaffected;
|
|
16
|
+
// windowed queries (getVelocity/getTokensInWindow) only need the recent tail,
|
|
17
|
+
// which 500 records covers by orders of magnitude. Uncapped, tokens.json grew
|
|
18
|
+
// to 13.8MB and was rewritten on every assistant stream event.
|
|
19
|
+
const SESSION_CAP = 500;
|
|
20
|
+
// Debounce window for persisting to disk. record() fires per assistant event —
|
|
21
|
+
// writing the full ledger each time is a hot-path full-file rewrite.
|
|
22
|
+
const SAVE_DEBOUNCE_MS = 5_000;
|
|
15
23
|
|
|
16
24
|
export class TokenTracker {
|
|
17
25
|
constructor(grooveDir) {
|
|
@@ -23,6 +31,7 @@ export class TokenTracker {
|
|
|
23
31
|
this.coldStartsSkipped = 0;
|
|
24
32
|
this.projectFiles = 0; // Set from indexer stats
|
|
25
33
|
this.projectDirs = 0;
|
|
34
|
+
this._saveTimer = null;
|
|
26
35
|
this.load();
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -47,6 +56,7 @@ export class TokenTracker {
|
|
|
47
56
|
if (!entry.totalDurationMs) entry.totalDurationMs = 0;
|
|
48
57
|
if (!entry.totalTurns) entry.totalTurns = 0;
|
|
49
58
|
if (!entry.modelDistribution) entry.modelDistribution = {};
|
|
59
|
+
this._capSessions(entry);
|
|
50
60
|
}
|
|
51
61
|
} catch {
|
|
52
62
|
this.usage = {};
|
|
@@ -55,6 +65,10 @@ export class TokenTracker {
|
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
save() {
|
|
68
|
+
if (this._saveTimer) {
|
|
69
|
+
clearTimeout(this._saveTimer);
|
|
70
|
+
this._saveTimer = null;
|
|
71
|
+
}
|
|
58
72
|
writeFileSync(this.path, JSON.stringify({
|
|
59
73
|
usage: this.usage,
|
|
60
74
|
rotationSavings: this.rotationSavings,
|
|
@@ -64,6 +78,30 @@ export class TokenTracker {
|
|
|
64
78
|
}, null, 2));
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
// Debounced persistence for hot-path recording. In-memory state is always
|
|
82
|
+
// current; at most SAVE_DEBOUNCE_MS of records are at risk on a crash.
|
|
83
|
+
_scheduleSave() {
|
|
84
|
+
if (this._saveTimer) return;
|
|
85
|
+
this._saveTimer = setTimeout(() => {
|
|
86
|
+
this._saveTimer = null;
|
|
87
|
+
try { this.save(); } catch { /* best-effort */ }
|
|
88
|
+
}, SAVE_DEBOUNCE_MS);
|
|
89
|
+
this._saveTimer.unref?.();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Write any pending debounced state to disk. Call on daemon shutdown.
|
|
93
|
+
flush() {
|
|
94
|
+
if (this._saveTimer) {
|
|
95
|
+
try { this.save(); } catch { /* best-effort */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_capSessions(entry) {
|
|
100
|
+
if (Array.isArray(entry.sessions) && entry.sessions.length > SESSION_CAP) {
|
|
101
|
+
entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
_initAgent(agentId) {
|
|
68
106
|
if (!this.usage[agentId]) {
|
|
69
107
|
this.usage[agentId] = {
|
|
@@ -89,7 +127,8 @@ export class TokenTracker {
|
|
|
89
127
|
if (typeof detail === 'number') {
|
|
90
128
|
entry.total += detail;
|
|
91
129
|
entry.sessions.push({ tokens: detail, timestamp: new Date().toISOString() });
|
|
92
|
-
this.
|
|
130
|
+
this._capSessions(entry);
|
|
131
|
+
this._scheduleSave();
|
|
93
132
|
return;
|
|
94
133
|
}
|
|
95
134
|
|
|
@@ -121,8 +160,9 @@ export class TokenTracker {
|
|
|
121
160
|
projectRoot: detail.projectRoot || null,
|
|
122
161
|
timestamp: new Date().toISOString(),
|
|
123
162
|
});
|
|
163
|
+
this._capSessions(entry);
|
|
124
164
|
|
|
125
|
-
this.
|
|
165
|
+
this._scheduleSave();
|
|
126
166
|
}
|
|
127
167
|
|
|
128
168
|
// Sum tokens recorded for an agent since a given timestamp.
|
|
@@ -152,25 +192,25 @@ export class TokenTracker {
|
|
|
152
192
|
if (costUsd) entry.totalCostUsd += costUsd;
|
|
153
193
|
if (durationMs) entry.totalDurationMs += durationMs;
|
|
154
194
|
if (turns) entry.totalTurns += turns;
|
|
155
|
-
this.
|
|
195
|
+
this._scheduleSave();
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
// Record that a rotation saved context tokens
|
|
159
199
|
recordRotation(agentId, tokensBefore) {
|
|
160
200
|
this.rotationSavings += Math.round(tokensBefore * 0.3); // ~30% of context was degraded
|
|
161
|
-
this.
|
|
201
|
+
this._scheduleSave();
|
|
162
202
|
}
|
|
163
203
|
|
|
164
204
|
// Record that a conflict was prevented (scope enforcement)
|
|
165
205
|
recordConflictPrevented() {
|
|
166
206
|
this.conflictsPrevented++;
|
|
167
|
-
this.
|
|
207
|
+
this._scheduleSave();
|
|
168
208
|
}
|
|
169
209
|
|
|
170
210
|
// Record that a cold-start was skipped (Journalist provided context)
|
|
171
211
|
recordColdStartSkipped() {
|
|
172
212
|
this.coldStartsSkipped++;
|
|
173
|
-
this.
|
|
213
|
+
this._scheduleSave();
|
|
174
214
|
}
|
|
175
215
|
|
|
176
216
|
// Set project size from indexer for dynamic cold-start estimation
|