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.
47 changes: 37 additions & 10 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 @@ -10599,6 +10605,30 @@ export class AgentSession {
if (!requestedSessionName) await this._assertRlmSubagentSessionNameAvailable(sessionName);
const startedAt = Date.now();
const parentAssistantForUsage = this._findLastAssistantMessage();
// Child completions accumulate in memory and flush one durable attribution
// entry per settle boundary (child agent_end, run settlement) instead of
// one journal append per child assistant message.
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
let pendingChildUsage: Usage | undefined;
let pendingChildUsageOrigin: ChildUsageAttributionEntry["origin"];
const flushPendingChildUsageAttribution = () => {
if (!pendingChildUsage || !parentAssistantForUsage) return;
const childUsage = pendingChildUsage;
const origin = pendingChildUsageOrigin;
pendingChildUsage = undefined;
pendingChildUsageOrigin = undefined;
const parentEntry = this._findAssistantEntryForMessage(parentAssistantForUsage);
if (!parentEntry) return;
try {
this.sessionManager.appendChildUsageAttribution(
parentEntry.id,
childUsage,
parentAssistantForUsage.usage,
origin,
);
} catch {
// Attribution is recoverable bookkeeping; a failed append must not break run settlement.
}
};
let runningToolCount = 0;
let childSession: AgentSession | undefined;
const run: RlmChildRun = {
Expand Down Expand Up @@ -10712,34 +10742,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") {
attributeChildUsage(parentAssistantForUsage?.usage ?? emptyUsage(), assistant.usage);
if (parentAssistantForUsage) {
const parentEntry = this._findAssistantEntryForMessage(parentAssistantForUsage);
if (parentEntry) {
if (!pendingChildUsage) {
pendingChildUsage = emptyUsage();
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 =
pendingChildUsageOrigin =
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,
);
}
addAssistantUsage(pendingChildUsage, assistant.usage);
}
}
const text = compactRlmText(readAssistantText(assistant));
Expand Down Expand Up @@ -10901,6 +10927,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,9 @@ 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;
// An idle session whose generation keeps failing on the same settled content
// stops retrying (and paying for model calls) until new activity arrives.
const IDLE_GENERATION_ATTEMPT_LIMIT = 3;

const SUMMARY_MODEL_PROVIDER = "prime-inference";
const SUMMARY_MODEL_ID = "qwen/qwen3-30b-a3b-instruct-2507";
Expand Down Expand Up @@ -207,6 +210,8 @@ 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 message count they saw.
private readonly failedIdleGenerations = new Map<string, { messageCount: number; attempts: number }>();

constructor(
private readonly listSessions: () => readonly ActiveSessionState[],
Expand Down Expand Up @@ -242,6 +247,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 +259,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 +313,12 @@ export class DaemonSessionSummarizer {
if (contentUnchanged && !isWorking && !owesIdleVerdict && !owesSummary) {
return;
}
// A generation that keeps failing on identical idle content will keep
// failing: stop re-attempting until the session produces new messages.
const failed = this.failedIdleGenerations.get(id);
if (!isWorking && failed?.messageCount === messageCount && failed.attempts >= IDLE_GENERATION_ATTEMPT_LIMIT) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
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 +332,14 @@ export class DaemonSessionSummarizer {
isWorking,
signal: controller.signal,
});
if (generated) {
this.failedIdleGenerations.delete(id);
} else if (!isWorking) {
this.failedIdleGenerations.set(id, {
messageCount,
attempts: failed?.messageCount === messageCount ? failed.attempts + 1 : 1,
});
}
// 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 +377,21 @@ 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, never
// mid-stream, never a fabricated fallback, and never a duplicate of the
// latest persisted entry: an idle sweep 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
8 changes: 5 additions & 3 deletions packages/coding-agent/test/agent-session-recursion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2462,7 +2462,7 @@ describe("AgentSession rlm recursion", () => {
expect(attribution.aggregateUsage.cost.total).toBe(10);
});

it("attributes every tool-loop turn in the admitted task to spawn usage", async () => {
it("coalesces the admitted task's tool-loop turns into one flushed spawn-usage attribution", async () => {
const tool = {
name: "echo",
description: "Echo a value",
Expand Down Expand Up @@ -2507,8 +2507,10 @@ describe("AgentSession rlm recursion", () => {
const attributions = root.sessionManager
.getEntries()
.filter((entry) => entry.type === "child_usage_attributed");
expect(attributions).toHaveLength(2);
expect(attributions.map((entry) => entry.origin)).toEqual(["spawn_task", "spawn_task"]);
expect(attributions).toHaveLength(1);
expect(attributions[0]?.origin).toBe("spawn_task");
expect(attributions[0]?.childUsage.input).toBe(3);
expect(attributions[0]?.childUsage.output).toBe(3);
});
});

Expand Down
54 changes: 53 additions & 1 deletion packages/coding-agent/test/daemon-session-summarizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ describe("daemon session summarizer", () => {
messages: AgentMessage[];
isSessionActive: boolean;
summaryState?: AgentStatus;
persistedStatus?: AgentStatus;
appendAgentStatus?: (status: AgentStatus) => void;
}): ActiveSessionState {
return {
activeSessionId: "active-1",
Expand All @@ -170,7 +172,10 @@ describe("daemon session summarizer", () => {
messages: options.messages,
modelRegistry: {},
state: { streamingMessage: undefined },
sessionManager: { appendAgentStatus: () => {} },
sessionManager: {
appendAgentStatus: options.appendAgentStatus ?? (() => {}),
getLatestAgentStatus: () => options.persistedStatus,
},
},
},
} as unknown as ActiveSessionState;
Expand Down Expand Up @@ -201,6 +206,53 @@ describe("daemon session summarizer", () => {
expect(onStatusChanged).toHaveBeenCalledOnce();
});

test("a failing idle generation never persists its fabricated fallback and stops retrying", async () => {
const appendAgentStatus = vi.fn();
const state = makeState({
messages: [userMessage("hi")],
isSessionActive: false,
appendAgentStatus,
});
const generate = vi.fn(async () => undefined);
const summarizer = new DaemonSessionSummarizer(() => [state], undefined, generate);
const internal = summarizer as unknown as { summarize(state: ActiveSessionState): Promise<void> };

for (let sweep = 0; sweep < 5; sweep++) {
await internal.summarize(state);
}

expect(appendAgentStatus).not.toHaveBeenCalled();
expect(generate).toHaveBeenCalledTimes(3);
expect(state.summaryState).toEqual({ summary: "", taskState: "needs_input", basedOnMessageCount: 1 });
});

test("an idle re-settle matching the latest persisted status appends nothing", async () => {
const appendAgentStatus = vi.fn();
const persisted: AgentStatus = {
summary: "Awaiting review",
taskState: "needs_input",
basedOnMessageCount: 1,
};
const state = makeState({
messages: [userMessage("hi")],
isSessionActive: false,
persistedStatus: persisted,
appendAgentStatus,
});

const onStatusChanged = vi.fn();
const summarizer = new DaemonSessionSummarizer(
() => [state],
onStatusChanged,
async () => ({ summary: "Awaiting review", taskState: "needs_input" as const }),
);
await (summarizer as unknown as { summarize(state: ActiveSessionState): Promise<void> }).summarize(state);

expect(appendAgentStatus).not.toHaveBeenCalled();
expect(onStatusChanged).toHaveBeenCalledOnce();
expect(state.summaryState).toEqual(persisted);
});

test("a working refresh with unchanged text stays quiet", async () => {
const previous: AgentStatus = { summary: "Working on it", taskState: "needs_input", basedOnMessageCount: 2 };
const state = makeState({
Expand Down
Loading