Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3829c67
feat(server): compact Claude threads before a usage limit outlasts th…
HomardetFred Oct 30, 2026
e9b0570
fix(server): usage-limit compaction respects Never and reads current …
HomardetFred Oct 30, 2026
527720d
Merge remote-tracking branch 'origin/main' into t3code/pr-27742/feat/…
HomardetFred Oct 30, 2026
bb5780d
test(server): mock an absent provider instance with Effect.undefined
HomardetFred Oct 30, 2026
6e62512
Merge remote-tracking branch 'github/main' into takeover-27742
maria-rcks Nov 1, 2026
a3f614f
fix(server): skip idle history reads below usage limits
maria-rcks Nov 1, 2026
6581c0d
merge: sync main into compact-before-usage-limit
maria-rcks Nov 2, 2026
58bb339
fix: preserve compaction checks after merging main
maria-rcks Nov 2, 2026
8c0c257
fix(web): route compact setting search to its owning scope
maria-rcks Nov 2, 2026
a2ddc70
merge: sync main into compact-before-usage-limit
maria-rcks Nov 2, 2026
8e2edb3
merge: sync main into compact-before-usage-limit
HomardetFred Nov 3, 2026
fafa957
fix(server): only session usage limits trigger compaction before a limit
HomardetFred Nov 4, 2026
bab4509
merge: sync main into compact-before-usage-limit
HomardetFred Nov 4, 2026
ee75282
Merge remote-tracking branch 'github/main' into feat/compact-before-u…
maria-rcks Nov 6, 2026
9120bcc
merge: sync main into compact-before-usage-limit
HomardetFred Nov 7, 2026
493458f
Merge remote-tracking branch 'origin/main' into t3code/pr-27742/feat/…
HomardetFred Nov 13, 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
8 changes: 8 additions & 0 deletions apps/mobile/src/features/settings/ThreadSettingsSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function AutoSettleSettingsRows(props: {
patch: Partial<AutoSettleSettings> & {
autoResumeLimitedThreads?: boolean;
snoozeLimitedThreads?: boolean;
compactBeforeUsageLimit?: boolean;
},
) => props.write(patch);

Expand Down Expand Up @@ -143,6 +144,13 @@ export function AutoSettleSettingsRows(props: {
disabled={disabledFor("snoozeLimitedThreads")}
onValueChange={(value) => writeToAll({ snoozeLimitedThreads: value })}
/>
<SettingsSwitchRow
icon="arrow.down.right.and.arrow.up.left"
label="Compact before usage limits"
value={uniformMobileSetting(displayTargets, "compactBeforeUsageLimit")}
disabled={disabledFor("compactBeforeUsageLimit")}
onValueChange={(value) => writeToAll({ compactBeforeUsageLimit: value })}
/>
</SettingsSection>
) : null}
<SettingsSection title="Auto-settle">
Expand Down
103 changes: 98 additions & 5 deletions apps/server/src/orchestration-v2/IdleCompaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
import {
DEFAULT_SERVER_SETTINGS,
EventId,
IDLE_COMPACTION_TEXT,
MessageId,
type OrchestrationV2ThreadProjection,
NodeId,
Expand All @@ -13,6 +14,7 @@ import {
RunId,
ThreadId,
} from "@t3tools/contracts";
import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand All @@ -21,12 +23,17 @@ import * as Stream from "effect/Stream";
import * as Tracer from "effect/Tracer";
import * as TestClock from "effect/testing/TestClock";

import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as IdleCompactionService from "./IdleCompactionService.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProjectionStore from "./ProjectionStore.ts";

import { idleCompactionWindow, type IdleCompactionRecords } from "./IdleCompaction.ts";
import {
idleCompactionAction,
idleCompactionWindow,
type IdleCompactionRecords,
} from "./IdleCompaction.ts";

const NOW_MS = Date.parse("2026-10-19T12:00:00.000Z");
const MINUTE_MS = 60_000;
Expand Down Expand Up @@ -192,10 +199,60 @@ describe("idleCompactionWindow", () => {
});
});

describe("idleCompactionAction", () => {
it("compacts near a usage limit that outlasts the cache, unless set to never", () => {
const window = { startMs: NOW_MS + 58 * MINUTE_MS, endMs: NOW_MS + 60 * MINUTE_MS };
const usage = (
usedPercent: number,
resetsInMs?: number,
kind: "session" | "weekly" = "session",
) => ({
id: kind === "session" ? "five_hour" : "seven_day",
kind,
label: kind === "session" ? "Session" : "Weekly",
usedPercent,
...(resetsInMs === undefined ? {} : { resetsAt: DateTime.formatIso(at(resetsInMs)) }),
});
const action = (
mode: "onReturn" | "beforeCacheExpires" | "off",
usageWindows: ReadonlyArray<ReturnType<typeof usage>>,
nowMs = NOW_MS,
compactBeforeUsageLimit = true,
) => idleCompactionAction({ mode, compactBeforeUsageLimit, window, usageWindows, nowMs });
const nearLimit = [usage(95, 90 * MINUTE_MS)];

expect(action("onReturn", nearLimit)).toBe("compact");
expect(action("beforeCacheExpires", nearLimit)).toBe("compact");
expect(action("off", nearLimit)).toBe("skip");
expect(action("onReturn", nearLimit, NOW_MS, false)).toBe("skip");
expect(action("onReturn", [usage(94, 90 * MINUTE_MS)])).toBe("skip");
expect(action("onReturn", [usage(95, 30 * MINUTE_MS)])).toBe("skip");
expect(action("onReturn", [usage(95)])).toBe("skip");
expect(action("onReturn", [usage(99, 3 * 24 * 60 * MINUTE_MS, "weekly")])).toBe("skip");
// Inside the cache window, only the cache-expiry mode compacts without a usage reason.
expect(action("onReturn", [], window.startMs)).toBe("skip");
expect(action("beforeCacheExpires", [])).toBe("wait");
expect(action("beforeCacheExpires", [], window.startMs)).toBe("compact");
});
});

describe("IdleCompactionService", () => {
it.effect.each([
{ mode: "onReturn", reads: 0 },
{ mode: "onReturn", usageLimit: true, reads: 0, dispatches: 0 },
{ mode: "onReturn", usageLimit: true, usedPercent: 94, reads: 0, dispatches: 0 },
{ mode: "onReturn", usageLimit: true, usedPercent: 95, reads: 1, dispatches: 1 },
{ mode: "off", usageLimit: true, usedPercent: 95, reads: 0, dispatches: 0 },
{
mode: "onReturn",
override: "off",
usageLimit: true,
usedPercent: 95,
reads: 0,
dispatches: 0,
},
{ mode: "off", reads: 0 },
{ mode: "off", usageLimit: true, reads: 0 },
{ mode: "beforeCacheExpires", reads: 1 },
{ mode: "beforeCacheExpires", override: "off", reads: 0 },
{ mode: "onReturn", override: "beforeCacheExpires", reads: 1 },
Expand All @@ -206,10 +263,11 @@ describe("IdleCompactionService", () => {
{ mode: "beforeCacheExpires", driver: "claudeAgent", instance: "custom-claude", reads: 1 },
{ mode: "beforeCacheExpires", unconfigured: true, reads: 1 },
] as const)(
"loads history only for eligible idle compaction: $mode/$override/$enabled/$projectDisabled/$instance/$unconfigured",
"loads history only for eligible idle compaction: $mode/$usageLimit/$override/$enabled/$projectDisabled/$instance/$unconfigured",
(scenario) => {
const test = {
override: "default",
usageLimit: false,
enabled: true,
projectDisabled: false,
...scenario,
Expand All @@ -220,7 +278,9 @@ describe("IdleCompactionService", () => {
"instance" in test ? test.instance : "claudeAgent",
);
const driver = ProviderDriverKind.make("driver" in test ? test.driver : "claudeAgent");
const base = records({ cacheExpiresInMs: 2 * MINUTE_MS });
const base = records({
cacheExpiresInMs: (test.usageLimit ? 60 : 2) * MINUTE_MS,
});
const data: OrchestrationV2ThreadProjection = {
...base,
thread: {
Expand Down Expand Up @@ -274,8 +334,13 @@ describe("IdleCompactionService", () => {
occurredAt: at(),
payload: data.runs[0]!,
}),
dispatch: () =>
dispatch: (command) =>
Effect.sync(() => {
expect(command).toMatchObject({
type: "message.dispatch",
text: IDLE_COMPACTION_TEXT,
threadId,
});
dispatches++;
return { sequence: 1, storedEvents: [] };
}),
Expand All @@ -288,10 +353,35 @@ describe("IdleCompactionService", () => {
return data;
}),
}),
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
getInstance: () =>
Effect.succeed(
"usedPercent" in test
? ({
snapshot: {
getSnapshot: Effect.succeed({
usageLimits: {
windows: [
{
id: "five_hour",
kind: "session",
label: "Session",
usedPercent: test.usedPercent,
resetsAt: DateTime.formatIso(at(90 * MINUTE_MS)),
},
],
},
}),
},
} as unknown as ProviderInstance)
: undefined,
),
}),
Layer.mock(ServerSettings.ServerSettingsService)({
getSettings: Effect.succeed({
...DEFAULT_SERVER_SETTINGS,
idleCompaction: test.mode,
compactBeforeUsageLimit: test.usageLimit,
providerInstances:
"unconfigured" in test
? {}
Expand All @@ -318,7 +408,7 @@ describe("IdleCompactionService", () => {
yield* Deferred.await(done);
}).pipe(Effect.provide(dependencies), Effect.withTracer(tracer));
expect(historyReads).toBe(test.reads);
expect(dispatches).toBe(test.reads);
expect(dispatches).toBe("dispatches" in test ? test.dispatches : test.reads);
});
},
);
Expand Down Expand Up @@ -397,6 +487,9 @@ describe("IdleCompactionService", () => {
),
}),
}),
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
getInstance: () => Effect.undefined,
}),
Layer.mock(ServerSettings.ServerSettingsService)({
getSettings: Effect.succeed({
...DEFAULT_SERVER_SETTINGS,
Expand Down
44 changes: 43 additions & 1 deletion apps/server/src/orchestration-v2/IdleCompaction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { isListedThreadLineage, type OrchestrationV2ThreadProjection } from "@t3tools/contracts";
import {
isListedThreadLineage,
type IdleCompactionMode,
type OrchestrationV2ThreadProjection,
type ServerProviderUsageWindow,
} from "@t3tools/contracts";
import { COMPACTION_SUGGESTION_MIN_TOKENS } from "@t3tools/shared/claudeCompaction";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import * as DateTime from "effect/DateTime";
Expand All @@ -10,6 +15,8 @@ const MIN_CONTEXT_TOKENS = COMPACTION_SUGGESTION_MIN_TOKENS;
// Start this long before the cache expires: enough for the session to resume
// and send the compaction while the cache is still warm.
const LEAD_MS = 2 * 60_000;
// Close enough to a usage limit that the next turn may well be the one it stops.
const NEAR_USAGE_LIMIT_PERCENT = 95;

export type IdleCompactionRecords = Pick<
OrchestrationV2ThreadProjection,
Expand Down Expand Up @@ -104,3 +111,38 @@ export function idleCompactionWindow(
if (!Number.isFinite(endMs) || nowMs >= endMs) return null;
return { runId: latestRun.id, startMs: endMs - LEAD_MS, endMs };
}

/**
* What to do with an idle compaction window. A nearly spent session window that
* resets after the cache expires compacts at once: a stop on that limit would
* resume against an expired cache. "Never" always wins.
*/
export function idleCompactionAction(input: {
readonly mode: IdleCompactionMode;
readonly compactBeforeUsageLimit: boolean;
readonly window: { readonly startMs: number; readonly endMs: number };
readonly usageWindows: ReadonlyArray<ServerProviderUsageWindow>;
readonly nowMs: number;
}): "compact" | "wait" | "skip" {
const { mode, window, nowMs } = input;
if (mode === "off") return "skip";
if (input.compactBeforeUsageLimit && usageLimitOutlastsCache(input.usageWindows, window.endMs))
return "compact";
if (mode !== "beforeCacheExpires") return "skip";
return nowMs < window.startMs ? "wait" : "compact";
}

export function usageLimitOutlastsCache(
usageWindows: ReadonlyArray<ServerProviderUsageWindow>,
cacheExpiresAt: number,
): boolean {
return usageWindows.some(
(usage) =>
// A weekly window can sit near its limit for days; compacting after every turn
// meanwhile would throw away context the user is still working with.
usage.kind === "session" &&
usage.usedPercent >= NEAR_USAGE_LIMIT_PERCENT &&
usage.resetsAt !== undefined &&
Date.parse(usage.resetsAt) > cacheExpiresAt,
);
}
44 changes: 34 additions & 10 deletions apps/server/src/orchestration-v2/IdleCompactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,27 @@ import * as Effect from "effect/Effect";
import * as FiberMap from "effect/FiberMap";
import * as Stream from "effect/Stream";

import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import { forkParked } from "../serverActivation.ts";
import * as ServerSettings from "../serverSettings.ts";
import { idleCompactionWindow } from "./IdleCompaction.ts";
import {
idleCompactionAction,
idleCompactionWindow,
usageLimitOutlastsCache,
} from "./IdleCompaction.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProjectionStore from "./ProjectionStore.ts";

/**
* Beta: when a run completes on a Claude instance set to compact before the
* cache expires, waits until shortly before its prompt cache expires and sends
* `/compact` as a visible T3 Code turn. Timers live in
* memory, so a server restart skips compactions that were still waiting.
* Beta: sends `/compact` shortly before a completed Claude run's prompt cache
* expires, or at once with `compactBeforeUsageLimit` when a nearly spent usage
* limit outlasts the cache. Timers live in memory; a restart skips waiting ones.
*/
export const make = Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const settings = yield* ServerSettings.ServerSettingsService;
const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;

const compactWhenDue = Effect.fn("IdleCompactionService.compactWhenDue")(
function* (threadId: ThreadId, completedRunId: RunId) {
Expand All @@ -40,16 +45,29 @@ export const make = Effect.gen(function* () {
const claudeInstance = Object.hasOwn(current.providerInstances, thread.providerInstanceId)
? current.providerInstances[thread.providerInstanceId]?.driver === "claudeAgent"
: thread.providerInstanceId === "claudeAgent";
const mode = resolveIdleCompaction(current, thread.providerInstanceId);
if (
!claudeInstance ||
resolveIdleCompaction(current, thread.providerInstanceId) !== "beforeCacheExpires" ||
mode === "off" ||
(mode !== "beforeCacheExpires" && !current.compactBeforeUsageLimit) ||
!resolveProjectProviderInstanceEnabled(
current,
thread.projectId,
thread.providerInstanceId,
)
)
return;
// The instance snapshot, not the registry list: the registry applies the
// run's last usage update asynchronously and can still be behind here.
const instance = current.compactBeforeUsageLimit
? yield* providerInstances.getInstance(thread.providerInstanceId)
: undefined;
const usageWindows =
instance === undefined
? []
: ((yield* instance.snapshot.getSnapshot).usageLimits?.windows ?? []);
const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
if (mode === "onReturn" && !usageLimitOutlastsCache(usageWindows, nowMs)) return;
const activeProviderThreadIds =
thread.activeProviderThreadId === null ? [] : [thread.activeProviderThreadId];
const records = yield* projections.getThreadRecords(
Expand Down Expand Up @@ -79,8 +97,7 @@ export const make = Effect.gen(function* () {
},
);
if (
resolveIdleCompaction(current, records.thread.providerInstanceId) !==
"beforeCacheExpires" ||
records.thread.providerInstanceId !== thread.providerInstanceId ||
!resolveProjectProviderInstanceEnabled(
current,
records.thread.projectId,
Expand All @@ -99,10 +116,17 @@ export const make = Effect.gen(function* () {
runId = latestRun.id;
continue;
}
const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
const window = idleCompactionWindow(records, nowMs);
if (window?.runId !== runId) return;
if (nowMs < window.startMs) {
const action = idleCompactionAction({
mode,
compactBeforeUsageLimit: current.compactBeforeUsageLimit,
window,
usageWindows,
nowMs,
});
if (action === "skip") return;
if (action === "wait") {
yield* Effect.sleep(Duration.millis(window.startMs - nowMs));
continue;
}
Expand Down
Loading
Loading