Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bb48f72
feat(server): compact idle threads before the prompt cache expires
maria-rcks Oct 14, 2026
34634b0
fix(server): skip idle compaction once the cache window has passed
maria-rcks Oct 14, 2026
54d0d18
fix(server): keep idle compaction out of pushes, sends, forks and lim…
maria-rcks Oct 14, 2026
54de9a2
fix(server): idle compaction respects project providers, skips child …
maria-rcks Oct 15, 2026
09c5999
fix(server,web,mobile): failed idle compaction never gates the queue …
maria-rcks Oct 15, 2026
ed6cd61
fix(server,web,mobile): idle compaction stays out of thread activity …
maria-rcks Oct 15, 2026
202002e
fix(server,web,mobile): idle compaction never blocks settle or shows …
maria-rcks Oct 15, 2026
8fb7a5e
fix(server,web,mobile): idle compaction stays out of agent, settlemen…
maria-rcks Oct 15, 2026
975e282
fix(server,web,mobile): idle compaction stays out of errors, wake tim…
maria-rcks Oct 15, 2026
6302353
fix(server,web,mobile): idle compaction stays out of merge-back histo…
maria-rcks Oct 15, 2026
959d86b
fix(server,web,mobile): idle compaction is hidden from chat state, st…
maria-rcks Oct 15, 2026
d9877fb
fix(server,web,mobile): idle compaction shows only its compaction row…
maria-rcks Oct 15, 2026
f237a8e
fix(server): forks inherit only the compaction row of idle compaction…
maria-rcks Oct 15, 2026
8c25fbb
fix(server,web,mobile): idle compaction stays out of diff review turn…
maria-rcks Oct 15, 2026
c0c9f55
fix(server,web,mobile): idle compaction stays out of rewind targets, …
maria-rcks Oct 15, 2026
dc20398
fix(server): rollback waits for an active idle compaction instead of …
maria-rcks Oct 15, 2026
8d62b7e
fix(server): merge-back sends during idle compaction get a clear retr…
maria-rcks Oct 15, 2026
16bf4fc
fix(server): idle compaction waits while a merge-back is pending
maria-rcks Oct 15, 2026
153b442
fix(muse): compaction leaves a buffered background report for its con…
maria-rcks Oct 15, 2026
2fbe0eb
fix(server,pi,opencode): idle compaction never swallows background re…
maria-rcks Oct 15, 2026
a4982e1
fix(server): idle compaction skips threads with open background items
maria-rcks Oct 15, 2026
84698a7
chore: merge main into idle compaction
maria-rcks Oct 15, 2026
6a25f82
chore: merge main into idle compaction
maria-rcks Oct 18, 2026
7468b78
fix(client-runtime): checkpoint summaries refresh when runs change
maria-rcks Oct 18, 2026
5793b38
fix(client-runtime): rebuild checkpoint summaries only when idle runs…
maria-rcks Oct 18, 2026
751ac7e
fix(provider-grok): read context size from update metadata
maria-rcks Oct 19, 2026
bfd4526
fix(server): skip idle compaction on cursor
maria-rcks Oct 19, 2026
a9c25c8
chore: merge main into idle compaction
maria-rcks Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/mobile/src/state/use-thread-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@ function firstRouteParam(value: string | string[] | undefined): string | null {
function latestUserMessageAt(
projection: OrchestrationV2ThreadProjection,
): OrchestrationV2ThreadShell["latestUserMessageAt"] {
const idleRunIds = new Set(
projection.runs.filter((run) => run.idleCompactionOfRunId !== undefined).map((run) => run.id),
);
for (let index = projection.messages.length - 1; index >= 0; index -= 1) {
const message = projection.messages[index];
if (message?.role === "user") {
if (message?.role === "user" && (message.runId === null || !idleRunIds.has(message.runId))) {
return message.createdAt;
}
}
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ const EnvServerConfig = Config.all({
traceMaxBytes: Config.Int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)),
traceMaxFiles: traceMaxFilesConfig,
traceBatchWindowMs: Config.Int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)),
idleCompactionDelayMs: Config.Int("T3CODE_IDLE_COMPACTION_DELAY_MS").pipe(
Config.option,
Config.map((value) => {
const delay = Option.getOrUndefined(value);
return delay === undefined ? undefined : Math.max(0, delay);
}),
),
otlpTracesUrl: Config.String("T3CODE_OTLP_TRACES_URL").pipe(
Config.option,
Config.map(Option.getOrUndefined),
Expand Down Expand Up @@ -448,6 +455,7 @@ export const resolveServerConfig = (
traceMinLevel: env.traceMinLevel,
traceTimingEnabled: env.traceTimingEnabled,
traceBatchWindowMs: env.traceBatchWindowMs,
idleCompactionDelayMs: env.idleCompactionDelayMs,
traceMaxBytes: env.traceMaxBytes,
traceMaxFiles: env.traceMaxFiles,
otlpTracesUrl: traces?.url,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export class ServerConfig extends Context.Service<
readonly devUrl: URL | undefined;
readonly devAuthToken?: Redacted.Redacted<string> | undefined;
readonly devAllowedOrigins: ReadonlyArray<string>;
/** Development evidence hook; normal delay is the provider cache TTL minus its margin. */
readonly idleCompactionDelayMs?: number | undefined;
readonly noBrowser: boolean;
readonly startupPresentation: StartupPresentation;
readonly desktopBootstrapToken: string | undefined;
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const parentThreadId = ThreadId.make("thread-mcp-orchestrator-parent");
const childThreadId = ThreadId.make("thread-mcp-orchestrator-child");
const activeRunId = RunId.make("run-mcp-active");
const cancelledRunId = RunId.make("run-mcp-cancelled");
const maintenanceRunId = RunId.make("run-mcp-idle-compaction");
const childRunId = RunId.make("run-mcp-child");
const taskId = NodeId.make("node-mcp-task-1");
const now = DateTime.makeUnsafe("2026-08-04T12:00:00.000Z");
Expand Down Expand Up @@ -127,6 +128,10 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
runs: [
makeRun({ id: activeRunId, ordinal: 1, status: "running" }),
makeRun({ id: cancelledRunId, ordinal: 2, status: "cancelled" }),
{
...makeRun({ id: maintenanceRunId, ordinal: 3, status: "running" }),
idleCompactionOfRunId: activeRunId,
},
],
visibleTurnItems: [],
runtimeRequests: [],
Expand Down Expand Up @@ -179,6 +184,13 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
expect(result.thread.status).toBe("running");
expect(result.thread.latestRunId).toBe(cancelledRunId);
expect(result.thread.activeRunId).toBe(activeRunId);
expect(result.thread.runCount).toBe(2);
expect(result.recentRuns.map((run) => run.runId)).not.toContain(maintenanceRunId);
const limited = yield* service.readThread(makeScope(), {
threadId: parentThreadId,
runLimit: 1,
});
expect(limited.recentRuns.map((run) => run.runId)).toEqual([cancelledRunId]);
}).pipe(Effect.provide(layer), Effect.runPromise);
});

Expand Down
11 changes: 7 additions & 4 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import {
type ServerProvider,
ThreadId,
} from "@t3tools/contracts";
import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
import { isUserFacingRun, runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
import {
normalizeThreadPullRequestKey,
visibleThreadPullRequests,
Expand Down Expand Up @@ -399,6 +399,7 @@ export function hasPendingChildRuns(
): boolean {
return childProjection.runs.some(
(run) =>
isUserFacingRun(run) &&
!ThreadManagementService.isTerminalRunStatus(run.status) &&
!(run.status === "queued" && run.queueHeld === true) &&
(delegatedRun === undefined || run.ordinal > delegatedRun.ordinal),
Expand Down Expand Up @@ -522,6 +523,7 @@ function latestTerminalResultRun(
.filter(
(run) =>
ThreadManagementService.isTerminalRunStatus(run.status) &&
isUserFacingRun(run) &&
!monitorRunIds.has(run.id) &&
run.status !== "rolled_back" &&
(run.id === delegatedRun?.id || run.startedAt !== null),
Expand Down Expand Up @@ -790,7 +792,7 @@ function threadDetail(
worktreePath: projection.thread.worktreePath,
parentThreadId: projection.thread.lineage.parentThreadId,
relationshipToParent: projection.thread.lineage.relationshipToParent,
runCount: projection.runs.length,
runCount: projection.runs.filter(isUserFacingRun).length,
itemCount,
pendingRequestCount: projection.runtimeRequests.filter(
(request) => request.status === "pending",
Expand Down Expand Up @@ -1967,7 +1969,7 @@ const make = Effect.gen(function* () {
Effect.gen(function* () {
const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task");
const parentRun = parent.runs
.filter(ThreadManagementService.isActiveRun)
.filter((run) => isUserFacingRun(run) && ThreadManagementService.isActiveRun(run))
.toSorted((left, right) => right.ordinal - left.ordinal)[0];
if (
parentRun === undefined ||
Expand Down Expand Up @@ -2399,7 +2401,7 @@ const make = Effect.gen(function* () {
);
}
const projection = yield* loadProjection(threadId);
const run = projection.runs.at(-1);
const run = ThreadManagementService.latestRun(projection);
yield* threadManagement
.dispatch({
type: "thread.created.record",
Expand Down Expand Up @@ -2564,6 +2566,7 @@ const make = Effect.gen(function* () {
return {
thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis),
recentRuns: target.runs
.filter(isUserFacingRun)
.toSorted((left, right) => right.ordinal - left.ordinal)
.slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
.map(threadRun),
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { modelSelectionCommandType } from "@t3tools/shared/model";
import { isUserFacingRun } from "@t3tools/shared/orchestrationV2ThreadError";

import * as McpToolAccess from "../../McpToolAccess.ts";
import {
Expand All @@ -28,7 +29,9 @@ function queueEntry(
runId: RunId,
limit: number,
) {
const run = projection.runs.find((run) => run.id === runId && run.status === "queued");
const run = projection.runs.find(
(run) => isUserFacingRun(run) && run.id === runId && run.status === "queued",
);
const message = projection.messages.find((message) => message.id === run?.userMessageId);
if (run === undefined || message === undefined) return undefined;
const characters = Array.from(message.text);
Expand Down Expand Up @@ -231,7 +234,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
t3_queue_list: McpToolAccess.reads((input) =>
Effect.gen(function* () {
const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);
const runs = queuedRunsInDeliveryOrder(projection);
const runs = queuedRunsInDeliveryOrder(projection).filter(isUserFacingRun);
const cursor = input.cursor ?? 0;
const end = cursor + (input.limit ?? 20);
return {
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/orchestration-v2/CommandPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,27 @@ it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) =
);
});

it("queues every follow-up sent while idle compaction runs", () => {
const projection = dispatchProjection(baseCapabilities);
const idleCompaction = {
...projection,
runs: projection.runs.map((run) => ({
...run,
idleCompactionOfRunId: RunId.make("command-policy-previous-run"),
})),
};
for (const deliveryIntent of ["auto", "steer", "restart"] as const) {
assert.deepEqual(
CommandPolicy.resolveMessageDispatchIntent(
idleCompaction,
{ type: "start_immediately" },
deliveryIntent,
),
{ type: "queue_after_active" },
);
}
});

const layer = it.layer(CommandPolicy.layer);

layer("CommandPolicyV2", (it) => {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/CommandPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ function resolveDeliveryIntent(
run.status === "waiting",
);
if (activeRun === undefined) return { type: "start_immediately" };
// Idle compaction is invisible maintenance: whatever the user's follow-up
// preference, their message waits for it instead of steering into it.
if (activeRun.idleCompactionOfRunId !== undefined) return { type: "queue_after_active" };
if (deliveryIntent === "steer") {
return { type: "steer_active", targetRunId: activeRun.id };
}
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/orchestration-v2/ContextHandoffService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,9 +445,14 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi
),
);
const runStatuses = new Map(input.runs?.map((run) => [run.id, run.status]));
// Idle compaction is maintenance; its "/compact" prompt is not conversation.
const idleRunIds = new Set(
input.runs?.filter((run) => run.idleCompactionOfRunId !== undefined).map((run) => run.id),
);
const coverage = handoffCoverage(input);
const selected = selectHistory({
messages: input.items.flatMap((item) => {
if (item.runId !== null && idleRunIds.has(item.runId)) return [];
const message = historicalMessage(item);
return message === null
? []
Expand Down
153 changes: 153 additions & 0 deletions apps/server/src/orchestration-v2/IdleCompaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { OrchestrationV2ThreadProjection, ProviderDriverKind } from "@t3tools/contracts";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import * as DateTime from "effect/DateTime";

const MINUTE_MS = 60 * 1_000;

// How long each provider keeps a prompt cache warm through T3's runtime, from
// provider docs as of 2026-10. Claude subscriptions get 1h (API keys get 5m,
// whose cache has already expired by then). Modern OpenAI models keep about
// 30m; providers without a documented TTL use the same conservative 30m.
// Only drivers whose adapters expose compactThread are listed. ACP Registry
// flavors do not advertise supportsCompaction and must not receive /compact.
// Cursor is left out: its SDK passes /compress to the model as plain text, so
// the turn summarizes in chat and the next turn resends the full context.
export const PROMPT_CACHE_TTL_MS: Readonly<Record<string, number>> = {
claudeAgent: 60 * MINUTE_MS,
codex: 30 * MINUTE_MS,
opencode: 30 * MINUTE_MS,
muse: 30 * MINUTE_MS,
pi: 30 * MINUTE_MS,
grok: 30 * MINUTE_MS,
antigravity: 30 * MINUTE_MS,
};
const DEFAULT_PROMPT_CACHE_TTL_MS = 30 * MINUTE_MS;
export const MAX_PROMPT_CACHE_TTL_MS = Math.max(...Object.values(PROMPT_CACHE_TTL_MS));

// Summarizing small contexts can cost more than re-reading them. Require the
// same measured occupancy shown by the context meter, at least 20k tokens.
export const MIN_IDLE_COMPACTION_CONTEXT_TOKENS = 20_000;

/**
* The window, in epoch ms, when an idle thread should compact: the last 10% of
* its provider's cache TTL. Past `endMs` the cache is cold, so compacting would
* pay a full uncached read for a thread that may never be reopened.
*/
export function idleCompactionWindow(
completedAt: DateTime.Utc,
driver: ProviderDriverKind,
delayOverrideMs?: number,
): { readonly startMs: number; readonly endMs: number } {
const completedMs = DateTime.toEpochMillis(completedAt);
const ttl = PROMPT_CACHE_TTL_MS[driver] ?? DEFAULT_PROMPT_CACHE_TTL_MS;
const margin = Math.round(ttl / 10);
const delay = delayOverrideMs ?? ttl - margin;
return { startMs: completedMs + delay, endMs: completedMs + delay + margin };
}

/** Rechecked inside serialized message dispatch, so a stale sweep cannot wake work. */
export function idleCompactionCandidate(
projection: OrchestrationV2ThreadProjection,
enabled: boolean,
nowMs: number,
) {
const { thread, runs, messages, providerThreads, providerTurns, runtimeRequests, turnItems } =
projection;
if (!enabled || thread.deletedAt !== null || thread.archivedAt !== null) return null;
if (thread.lineage.relationshipToParent === "subagent") return null;
// A pending merge-back belongs to the user's next message; compaction would
// consume it and bind the merged context to the current provider thread.
if (
projection.contextTransfers.some(
(transfer) =>
transfer.type === "merge_back" &&
transfer.targetThreadId === thread.id &&
transfer.status === "pending",
)
)
return null;
if (thread.settledOverride === "settled" || thread.settledAt !== null) return null;
if (runtimeRequests.some((request) => request.status === "pending")) return null;
if (
runs.some((run) =>
["preparing", "queued", "starting", "running", "waiting"].includes(run.status),
)
) {
return null;
}
const latestRun = runs.reduce<(typeof runs)[number] | null>(
(latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest),
null,
);
if (latestRun?.status !== "completed" || latestRun.completedAt === null) return null;
if (latestRun.idleCompactionOfRunId !== undefined) return null;
const message = messages.find((candidate) => candidate.id === latestRun.userMessageId);
if (message === undefined || message.text.trim().toLowerCase() === "/compact") return null;
// A snooze that already woke on normal completion may compact, but a parked
// snooze must retain its wake time and never be resumed by maintenance.
if (
thread.snoozedUntil != null &&
DateTime.toEpochMillis(thread.snoozedUntil) > nowMs &&
(thread.snoozedAt == null || DateTime.Order(latestRun.completedAt, thread.snoozedAt) <= 0)
)
return null;
// Any pending background work (even a dev server that does not hold
// completion) can report into the next turn; a hidden compaction would
// swallow that report, notably when Claude resumes a released process.
if (
derivePendingBackgroundWork({
latestRun,
runs,
providerThreads,
turnItems,
activeProviderThreadId: thread.activeProviderThreadId,
pullRequests: thread.pullRequests,
}).length > 0 ||
// Persistent monitors are left out of pending background work but still report.
turnItems.some(
(item) =>
(item.type === "command_execution" ||
item.type === "dynamic_tool" ||
item.type === "subagent") &&
(item.status === "pending" || item.status === "running" || item.status === "waiting"),
)
)
return null;
// Include unadopted messages, even if a queued run has not been persisted yet.
if (
messages.some(
(candidate) =>
candidate.role === "user" &&
DateTime.Order(candidate.updatedAt, latestRun.completedAt!) > 0,
)
)
return null;
const providerThread = providerThreads.find(
(candidate) => candidate.id === thread.activeProviderThreadId,
);
if (
providerThread === undefined ||
providerThread.id !== latestRun.providerThreadId ||
providerThread.providerInstanceId !== thread.providerInstanceId ||
latestRun.providerInstanceId !== thread.providerInstanceId ||
!Object.hasOwn(PROMPT_CACHE_TTL_MS, providerThread.driver)
)
return null;
const usage = providerTurns
.filter((turn) => turn.providerThreadId === providerThread.id && turn.tokenUsage !== undefined)
.map((turn) => turn.tokenUsage!)
.toSorted((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))[0];
const usedTokens = usage?.usedTokens ?? providerThread.contextUsage?.usedTokens;
if (
usedTokens === undefined ||
!Number.isFinite(usedTokens) ||
usedTokens < MIN_IDLE_COMPACTION_CONTEXT_TOKENS
)
return null;
return {
threadId: thread.id,
runId: latestRun.id,
completedAt: latestRun.completedAt,
driver: providerThread.driver,
};
}
Loading
Loading