Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed unbounded session-journal growth from derived bookkeeping: child usage attribution now flushes one entry per child turn instead of one per model request, and idle status sweeps no longer persist fabricated fallback verdicts, duplicate statuses, or retry failed summary generations (including paid model calls) every 25 seconds on unchanged content.
101 changes: 81 additions & 20 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,13 @@ import {
transitionSessionAction,
type WakePolicy,
} from "./session-action-store.js";
import type { BranchSummaryEntry, CompactionEntry, SessionContext, SessionMessageEntry } from "./session-manager.js";
import type {
BranchSummaryEntry,
ChildUsageAttributionEntry,
CompactionEntry,
SessionContext,
SessionMessageEntry,
} from "./session-manager.js";
import {
CURRENT_SESSION_VERSION,
getLatestCompactionEntry,
Expand Down Expand Up @@ -1037,6 +1043,26 @@ function waitForPromiseOrAbort<T>(
});
}

// Bounds how much accumulated child usage a parent process crash can lose.
const RLM_CHILD_USAGE_FLUSH_MAX_PENDING_MS = 60_000;

/** Label a child completion's usage by the nearest preceding prompt that triggered it. */
function rlmChildUsageOrigin(
messages: readonly AgentMessage[],
assistant: AssistantMessage,
): ChildUsageAttributionEntry["origin"] {
for (let index = messages.lastIndexOf(assistant) - 1; index >= 0; index--) {
const message = messages[index];
if (message.role !== "user" && message.role !== "custom") continue;
return message.role === "custom" && isAgentSessionMessage(message)
? message.details.id.startsWith("spawn:")
? "spawn_task"
: "agent_message"
: "direct_user";
}
return "direct_user";
}

function attributeChildUsage(parentUsage: Usage, childUsage: Usage): void {
const parentContextTokens =
parentUsage.totalTokens ||
Expand Down Expand Up @@ -10599,6 +10625,43 @@ export class AgentSession {
if (!requestedSessionName) await this._assertRlmSubagentSessionNameAvailable(sessionName);
const startedAt = Date.now();
const parentAssistantForUsage = this._findLastAssistantMessage();
// Child completions accumulate per origin and flush one durable entry per
// settle boundary (agent_end, settlement); the staleness checkpoints and
// timer bound crash loss to one window of accumulated usage.
const pendingChildUsage = new Map<ChildUsageAttributionEntry["origin"], Usage>();
let pendingChildUsageSince = 0;
let pendingChildUsageTimer: ReturnType<typeof setTimeout> | undefined;
const flushPendingChildUsageAttribution = () => {
if (pendingChildUsageTimer !== undefined) {
clearTimeout(pendingChildUsageTimer);
pendingChildUsageTimer = undefined;
}
if (pendingChildUsage.size === 0 || !parentAssistantForUsage) return;
const batches = [...pendingChildUsage.entries()];
pendingChildUsage.clear();
const parentEntry = this._findAssistantEntryForMessage(parentAssistantForUsage);
if (!parentEntry) return;
for (const [origin, childUsage] of batches) {
try {
this.sessionManager.appendChildUsageAttribution(
parentEntry.id,
childUsage,
parentAssistantForUsage.usage,
origin,
);
} catch {
// Attribution is recoverable bookkeeping; a failed append must not break run settlement.
}
}
};
const flushPendingChildUsageIfStale = () => {
if (
pendingChildUsage.size > 0 &&
Date.now() - pendingChildUsageSince >= RLM_CHILD_USAGE_FLUSH_MAX_PENDING_MS
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
) {
flushPendingChildUsageAttribution();
}
};
let runningToolCount = 0;
let childSession: AgentSession | undefined;
const run: RlmChildRun = {
Expand Down Expand Up @@ -10712,34 +10775,30 @@ export class AgentSession {
run.activity = { kind: "waiting" };
emitChildUpdate();
} else if (event.type === "agent_end") {
flushPendingChildUsageAttribution();
run.activity = undefined;
emitChildUpdate();
} else if (event.type === "message_end" && event.message.role === "assistant") {
const assistant = event.message as AssistantMessage;
if (assistant.stopReason !== "error" && assistant.stopReason !== "aborted") {
// Flush before the fold: a persisted aggregate may only include
// completions whose childUsage is durable with or before it.
flushPendingChildUsageIfStale();
attributeChildUsage(parentAssistantForUsage?.usage ?? emptyUsage(), assistant.usage);
if (parentAssistantForUsage) {
const parentEntry = this._findAssistantEntryForMessage(parentAssistantForUsage);
if (parentEntry) {
const messages = child.messages;
const assistantIndex = messages.lastIndexOf(assistant);
const precedingPrompt = messages
.slice(0, assistantIndex)
.reverse()
.find((message) => message.role === "user" || message.role === "custom");
const origin =
precedingPrompt?.role === "custom" && isAgentSessionMessage(precedingPrompt)
? precedingPrompt.details.id.startsWith("spawn:")
? "spawn_task"
: "agent_message"
: "direct_user";
this.sessionManager.appendChildUsageAttribution(
parentEntry.id,
assistant.usage,
parentAssistantForUsage.usage,
origin,
const origin = rlmChildUsageOrigin(child.messages, assistant);
if (pendingChildUsage.size === 0) {
pendingChildUsageSince = Date.now();
// Wall-clock backstop for long tool runs without checkpoints.
pendingChildUsageTimer = setTimeout(
flushPendingChildUsageAttribution,
RLM_CHILD_USAGE_FLUSH_MAX_PENDING_MS,
);
pendingChildUsageTimer.unref?.();
}
const bucket = pendingChildUsage.get(origin) ?? emptyUsage();
addAssistantUsage(bucket, assistant.usage);
pendingChildUsage.set(origin, bucket);
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
const text = compactRlmText(readAssistantText(assistant));
Expand All @@ -10753,6 +10812,7 @@ export class AgentSession {
emitChildUpdate();
}
} else if (event.type === "tool_execution_start") {
flushPendingChildUsageIfStale();
run.toolUseCount += 1;
runningToolCount += 1;
run.activity = { kind: "executing", toolName: event.toolName };
Expand Down Expand Up @@ -10901,6 +10961,7 @@ export class AgentSession {
}
}
} finally {
flushPendingChildUsageAttribution();
if (run.detachedDeletion) {
run.deletionRunFinished = true;
if (!run.settled) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type { ActiveSessionState } from "./active-session-state.js";
const SWEEP_INTERVAL_MS = 25_000;
// Collapse a tool-use loop's rapid turn_end bursts into one summarization.
const SETTLE_DEBOUNCE_MS = 2_000;
// Idle generations stop retrying (and paying) on unchanged content until the
// backoff elapses, so transient outages and late credentials still recover.
const IDLE_GENERATION_ATTEMPT_LIMIT = 3;
const IDLE_GENERATION_RETRY_BACKOFF_MS = 30 * 60_000;

const SUMMARY_MODEL_PROVIDER = "prime-inference";
const SUMMARY_MODEL_ID = "qwen/qwen3-30b-a3b-instruct-2507";
Expand Down Expand Up @@ -207,6 +211,11 @@ export class DaemonSessionSummarizer {
private readonly inFlight = new Map<string, AbortController>();
// Sessions requested while one was running; get one more pass on completion.
private readonly rerunRequested = new Set<string>();
// Failed idle generations per session, keyed to the settled content they saw.
private readonly failedIdleGenerations = new Map<
string,
{ contentKey: string; attempts: number; lastFailureAt: number }
>();

constructor(
private readonly listSessions: () => readonly ActiveSessionState[],
Expand Down Expand Up @@ -242,6 +251,7 @@ export class DaemonSessionSummarizer {
controller.abort();
}
this.rerunRequested.clear();
this.failedIdleGenerations.clear();
}

/** Drop any pending work for a session that is closing. */
Expand All @@ -253,6 +263,7 @@ export class DaemonSessionSummarizer {
}
this.inFlight.get(activeSessionId)?.abort();
this.rerunRequested.delete(activeSessionId);
this.failedIdleGenerations.delete(activeSessionId);
}

/** Seed in-memory status from the persisted entry when a session is added. */
Expand Down Expand Up @@ -306,6 +317,18 @@ export class DaemonSessionSummarizer {
if (contentUnchanged && !isWorking && !owesIdleVerdict && !owesSummary) {
return;
}
// The leaf entry id is the branch-tip identity (appends, edits, and branch
// navigation all move it; counts and timestamps collide across siblings).
const contentKey = `${session.sessionManager.getLeafId() ?? "root"}:${messageCount}`;
const failed = this.failedIdleGenerations.get(id);
if (
!isWorking &&
failed?.contentKey === contentKey &&
failed.attempts >= IDLE_GENERATION_ATTEMPT_LIMIT &&
Date.now() - failed.lastFailureAt < IDLE_GENERATION_RETRY_BACKOFF_MS
) {
return;
}
// Include the in-progress message so a long streaming turn gets a live recap.
const streaming = isWorking ? session.state.streamingMessage : undefined;
const contextMessages = streaming ? [...messages, streaming] : messages;
Expand All @@ -319,6 +342,16 @@ export class DaemonSessionSummarizer {
isWorking,
signal: controller.signal,
});
if (generated) {
this.failedIdleGenerations.delete(id);
} else if (!isWorking && !controller.signal.aborted) {
// The aborted check keeps a racing forget() from repopulating the map.
this.failedIdleGenerations.set(id, {
contentKey,
attempts: failed?.contentKey === contentKey ? failed.attempts + 1 : 1,
lastFailureAt: Date.now(),
});
}
// A failed classification on an idle session would spin at "working"
// forever (the activity axis holds unjudged idle sessions there), so
// settle it to needs_input.
Expand Down Expand Up @@ -356,12 +389,20 @@ export class DaemonSessionSummarizer {
previous?.taskState !== status.taskState ||
(!isWorking && previous?.basedOnMessageCount !== status.basedOnMessageCount);
state.summaryState = status;
// Persist only settled idle verdicts, never mid-stream.
if (!isWorking) {
try {
session.sessionManager.appendAgentStatus(status);
} catch {
// best-effort; in-memory status still shows
// Persist only settled idle verdicts from real classifications that
// differ from the latest persisted entry: idle sweeps must not grow the journal.
if (!isWorking && generated) {
const persisted = session.sessionManager.getLatestAgentStatus();
if (
persisted?.summary !== status.summary ||
persisted.taskState !== status.taskState ||
persisted.basedOnMessageCount !== status.basedOnMessageCount
) {
try {
session.sessionManager.appendAgentStatus(status);
} catch {
// best-effort; in-memory status still shows
}
}
}
if (changed) {
Expand Down
Loading
Loading