Skip to content

Commit e43d5f4

Browse files
committed
fix(usage): wake limited threads once their account shows headroom
1 parent 228e289 commit e43d5f4

11 files changed

Lines changed: 272 additions & 28 deletions

‎apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,10 @@ it.effect.each([
183183
Layer.provide(
184184
Layer.mergeAll(
185185
layerAdapters,
186-
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
186+
Layer.mock(ProviderRegistry.ProviderRegistry)({
187+
getProviders: Effect.succeed([]),
188+
streamChanges: Stream.never,
189+
}),
187190
Layer.mock(ProjectService.ProjectService)({}),
188191
Layer.mock(SecretRequests.SecretRequests)({}),
189192
Layer.mock(RemoteDelegation.RemoteDelegation)({}),

‎apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
12
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
23
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
34
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
@@ -148,6 +149,9 @@ const layerTest = Layer.mergeAll(
148149
Layer.provide(layerCheckpointStoreTest),
149150
Layer.provide(layerServerConfig),
150151
Layer.provide(ServerSettings.layerTest()),
152+
Layer.provide(
153+
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
154+
),
151155
Layer.provide(layerTestProviderInstanceRegistry),
152156
Layer.provide(layerPlatformTest),
153157
);

‎apps/server/src/orchestration-v2/Orchestrator.ts‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { hasUsageLimitHeadroom } from "@t3tools/shared/usageLimits";
2+
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
13
import type {
24
GuestShareId,
35
OrchestrationV2SearchThreadInput,
@@ -382,6 +384,7 @@ export interface OrchestratorV2Shape {
382384
readonly commandId: CommandId;
383385
readonly threadId: ThreadId;
384386
readonly limitRecovery: OrchestrationV2LimitRecoveryUpdate;
387+
readonly expectedRecoveryRequestId?: CommandId | undefined;
385388
}) => Effect.Effect<void, OrchestratorV2Error>;
386389
readonly searchThreadStream: (
387390
input: OrchestrationV2SearchThreadInput,
@@ -1048,6 +1051,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10481051
const fileSystem = yield* FileSystem.FileSystem;
10491052
const path = yield* Path.Path;
10501053
const providerAdapters = yield* ProviderAdapterRegistryV2;
1054+
const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
10511055
const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
10521056
const providerSessions = yield* ProviderSessionManagerV2;
10531057
const providerSwitchService = yield* ProviderSwitchServiceV2;
@@ -5425,6 +5429,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
54255429
const failure = latestRootProviderFailure(run, projection.turnItems);
54265430
const recovery = projection.thread.limitRecovery;
54275431
const now = yield* DateTime.now;
5432+
const recovered =
5433+
run != null && Date.parse(recovery?.resetAt ?? "") > DateTime.toEpochMillis(now)
5434+
? hasUsageLimitHeadroom(
5435+
(yield* providerRegistry.getProviders).find(
5436+
(provider) => provider.instanceId === run.providerInstanceId,
5437+
)?.usageLimits,
5438+
DateTime.toEpochMillis(run.completedAt ?? run.requestedAt),
5439+
)
5440+
: false;
54285441
if (
54295442
run?.id !== command.usageLimitContinuationOfRunId ||
54305443
failure?.class !== "usage_limit" ||
@@ -5433,14 +5446,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
54335446
recovery.requestId !== command.usageLimitRecoveryRequestId ||
54345447
recovery.runId !== run.id ||
54355448
recovery.resetAt !== failure.resetAt ||
5436-
Date.parse(recovery.resetAt) > DateTime.toEpochMillis(now) ||
5449+
(Date.parse(recovery.resetAt) > DateTime.toEpochMillis(now) && !recovered) ||
54375450
projection.thread.archivedAt !== null ||
54385451
projection.thread.deletedAt !== null ||
54395452
projection.thread.settledOverride === "settled" ||
54405453
projection.thread.providerInstanceId !== run.providerInstanceId ||
54415454
projection.runtimeRequests.some((request) => request.status === "pending") ||
54425455
(projection.thread.snoozedUntil != null &&
5443-
DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now))
5456+
DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now) &&
5457+
!(
5458+
recovered &&
5459+
recovery.snooze &&
5460+
DateTime.toEpochMillis(projection.thread.snoozedUntil) === Date.parse(recovery.resetAt)
5461+
))
54445462
) {
54455463
yield* emit(
54465464
events,
@@ -5454,8 +5472,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
54545472
...(recovery?.autoResume &&
54555473
recovery.requestId === command.usageLimitRecoveryRequestId &&
54565474
recovery.runId === command.usageLimitContinuationOfRunId &&
5457-
projection.thread.snoozedUntil != null &&
5458-
DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now)
5475+
((projection.thread.snoozedUntil != null &&
5476+
DateTime.toEpochMillis(projection.thread.snoozedUntil) >
5477+
DateTime.toEpochMillis(now)) ||
5478+
(Date.parse(recovery.resetAt) > DateTime.toEpochMillis(now) && !recovered))
54595479
? { limitRecovery: { ...recovery, requestId: command.commandId } }
54605480
: {}),
54615481
},
@@ -14051,6 +14071,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1405114071
(cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }),
1405214072
),
1405314073
);
14074+
if (!input.limitRecovery.autoResume && !input.limitRecovery.snooze) {
14075+
const current = thread.limitRecovery;
14076+
if (
14077+
current?.runId === input.limitRecovery.runId &&
14078+
current.resetAt === input.limitRecovery.resetAt &&
14079+
current.requestId === input.expectedRecoveryRequestId &&
14080+
!current.autoResume &&
14081+
current.snooze
14082+
) {
14083+
yield* dispatchWithReceiptEffect({ type: "thread.metadata.update", ...input });
14084+
}
14085+
return;
14086+
}
1405414087
// Stop or an explicit recovery choice can commit after the sweep reads its candidate.
1405514088
if (
1405614089
thread.limitRecovery?.runId === input.limitRecovery.runId &&
@@ -14247,6 +14280,7 @@ export const layer: Layer.Layer<
1424714280
| IdAllocator.IdAllocatorV2
1424814281
| ProjectStore.ProjectStoreV2
1424914282
| ProviderAdapterRegistryV2
14283+
| ProviderRegistry.ProviderRegistry
1425014284
| ProviderSessionManagerV2
1425114285
| ProviderSwitchServiceV2
1425214286
| ProjectionStoreV2

‎apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
12
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
23
import * as NodeServices from "@effect/platform-node/NodeServices";
34
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
@@ -156,6 +157,9 @@ const layerRuntime = (dbPath: string) => {
156157
Layer.provide(layerCheckpointStore),
157158
Layer.provide(layerServerConfig),
158159
Layer.provide(ServerSettings.layerTest()),
160+
Layer.provide(
161+
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
162+
),
159163
Layer.provide(
160164
Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, {
161165
// @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type.

‎apps/server/src/orchestration-v2/ProjectionStore.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5419,9 +5419,10 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
54195419
status: sqlShell.status,
54205420
lastErrorClass: sqlShell.lastErrorClass,
54215421
usageLimitResetAt: sqlShell.usageLimitResetAt,
5422+
providerInstanceId: sqlShell.providerInstanceId,
54225423
latestRunId: sqlShell.latestRunId,
54235424
latestRunCompletedAt: sqlShell.latestRunCompletedAt,
5424-
updatedAt: sqlShell.updatedAt,
5425+
latestRunRequestedAt: sqlShell.latestRunRequestedAt,
54255426
archivedAt: sqlShell.archivedAt,
54265427
settledOverride: sqlShell.settledOverride,
54275428
pendingRuntimeRequest: null,
@@ -5594,7 +5595,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
55945595
yield* sql`UPDATE orchestration_v2_projection_threads
55955596
SET payload_json = json_set(payload_json, '$.limitRecovery', json(${encodeUnknownJsonString(recovery)}))
55965597
WHERE thread_id = ${threadId}`;
5597-
assert.isUndefined(
5598+
assert.isDefined(
55985599
(yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, autoResume: true })).find(
55995600
(row) => row.id === threadId,
56005601
),
@@ -5618,7 +5619,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
56185619
yield* sql`UPDATE orchestration_v2_projection_threads
56195620
SET payload_json = json_set(payload_json, '$.snoozedUntil', ${DateTime.formatIso(DateTime.add(reset, { minutes: 1 }))})
56205621
WHERE thread_id = ${threadId}`;
5621-
assert.isUndefined(
5622+
assert.isDefined(
56225623
(yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, now: reset })).find(
56235624
(row) => row.id === threadId,
56245625
),

‎apps/server/src/orchestration-v2/ProjectionStore.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,12 @@ export type ProjectionLimitRecoveryCandidate = Pick<
177177
| "lastErrorClass"
178178
| "latestRunId"
179179
| "usageLimitResetAt"
180+
| "providerInstanceId"
180181
| "archivedAt"
181182
| "settledOverride"
182183
| "pendingRuntimeRequest"
183184
| "latestRunCompletedAt"
184-
| "updatedAt"
185+
| "latestRunRequestedAt"
185186
| "limitRecovery"
186187
| "snoozedUntil"
187188
>;
@@ -3948,10 +3949,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
39483949
readonly payload_json: string;
39493950
readonly run_id: string;
39503951
readonly completed_at: string | null;
3952+
readonly requested_at: string;
39513953
readonly failure_payload_json: string;
39523954
readonly last_error: string | null;
39533955
}>`
3954-
SELECT t.payload_json, r.run_id, r.completed_at,
3956+
SELECT t.payload_json, r.run_id, r.completed_at, r.requested_at,
39553957
item.payload_json AS failure_payload_json,
39563958
(
39573959
SELECT json_extract(session.payload_json, '$.lastError')
@@ -3978,6 +3980,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
39783980
latest.ordinal DESC, latest.run_id DESC
39793981
LIMIT 1
39803982
) AND r.status = 'failed'
3983+
AND r.provider_instance_id = t.provider_instance_id
39813984
INNER JOIN orchestration_v2_projection_turn_items item ON item.turn_item_id = (
39823985
SELECT error.turn_item_id FROM orchestration_v2_projection_turn_items error
39833986
WHERE error.thread_id = t.thread_id AND error.run_id = r.run_id
@@ -4004,11 +4007,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
40044007
(
40054008
json_extract(t.payload_json, '$.limitRecovery.runId') IS r.run_id
40064009
AND json_extract(t.payload_json, '$.limitRecovery.resetAt') IS json_extract(item.payload_json, '$.failure.resetAt')
4007-
AND json_extract(t.payload_json, '$.limitRecovery.autoResume') = 1
4008-
AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) <= julianday(${DateTime.formatIso(options.now)})
40094010
AND (
4010-
json_extract(t.payload_json, '$.snoozedUntil') IS NULL
4011-
OR julianday(json_extract(t.payload_json, '$.snoozedUntil')) <= julianday(${DateTime.formatIso(options.now)})
4011+
json_extract(t.payload_json, '$.limitRecovery.autoResume') = 1
4012+
OR json_extract(t.payload_json, '$.limitRecovery.snooze') = 1
40124013
)
40134014
)
40144015
OR (
@@ -4049,10 +4050,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
40494050
status: "failed",
40504051
lastErrorClass: summary.lastErrorClass,
40514052
usageLimitResetAt: summary.usageLimitResetAt,
4053+
providerInstanceId: thread.providerInstanceId,
40524054
latestRunId: RunId.make(row.run_id),
4055+
latestRunRequestedAt: DateTime.makeUnsafe(row.requested_at),
40534056
latestRunCompletedAt:
40544057
row.completed_at === null ? null : DateTime.makeUnsafe(row.completed_at),
4055-
updatedAt: thread.updatedAt,
40564058
archivedAt: thread.archivedAt,
40574059
settledOverride: thread.settledOverride,
40584060
pendingRuntimeRequest: null,

‎apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts‎

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { assert, describe, it } from "@effect/vitest";
2-
import { RunId, ThreadId, type OrchestrationV2DomainEvent } from "@t3tools/contracts";
2+
import {
3+
CommandId,
4+
ProviderInstanceId,
5+
RunId,
6+
ThreadId,
7+
type ServerProviderUsageLimits,
8+
type OrchestrationV2DomainEvent,
9+
} from "@t3tools/contracts";
310
import * as Clock from "effect/Clock";
411
import * as DateTime from "effect/DateTime";
512
import * as Effect from "effect/Effect";
@@ -8,6 +15,7 @@ import * as Queue from "effect/Queue";
815
import * as Stream from "effect/Stream";
916
import * as TestClock from "effect/testing/TestClock";
1017

18+
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
1119
import * as Scheduler from "../scheduling/Scheduler.ts";
1220
import * as ServerSettings from "../serverSettings.ts";
1321
import * as Orchestrator from "./Orchestrator.ts";
@@ -22,10 +30,15 @@ it.effect("idle sweeps skip SQL until the next wake time or a relevant event", (
2230
const startMs = Date.parse("2026-07-01T09:00:00.000Z");
2331
const queries = yield* Queue.unbounded<number>();
2432
const events = yield* Queue.unbounded<OrchestrationV2DomainEvent>();
33+
const providerChanges = yield* Queue.unbounded<never[]>();
2534
const layer = UsageLimitRecoveryWorker.layer.pipe(
2635
Layer.provide(
2736
Layer.mergeAll(
2837
Scheduler.layer,
38+
Layer.mock(ProviderRegistry.ProviderRegistry)({
39+
getProviders: Effect.succeed([]),
40+
streamChanges: Stream.fromQueue(providerChanges),
41+
}),
2942
Layer.mock(ProjectionStore.ProjectionStoreV2)({
3043
getLimitRecoveryCandidates: () =>
3144
Clock.currentTimeMillis.pipe(
@@ -66,6 +79,9 @@ it.effect("idle sweeps skip SQL until the next wake time or a relevant event", (
6679
yield* TestClock.adjust("5 seconds");
6780
// A streaming item at 90s does not wake it; the failed run does on the 95s tick.
6881
assert.equal(yield* Queue.take(queries), 95_000);
82+
yield* Queue.offer(providerChanges, []);
83+
yield* TestClock.adjust("5 seconds");
84+
assert.equal(yield* Queue.take(queries), 100_000);
6985
}).pipe(Effect.provide(layer));
7086
}),
7187
);
@@ -77,12 +93,13 @@ const failedThread = (failedAgoMs: number, resetAgoMs: number) => {
7793
const failedAt = DateTime.makeUnsafe(nowMs - failedAgoMs);
7894
const candidate: ProjectionStore.ProjectionLimitRecoveryCandidate = {
7995
id: ThreadId.make("thread:limited"),
96+
providerInstanceId: ProviderInstanceId.make("codex"),
8097
status: "failed",
8198
lastErrorClass: "usage_limit",
8299
usageLimitResetAt: DateTime.formatIso(DateTime.makeUnsafe(nowMs - resetAgoMs)),
83100
latestRunId: RunId.make("run:limited"),
84101
latestRunCompletedAt: failedAt,
85-
updatedAt: failedAt,
102+
latestRunRequestedAt: failedAt,
86103
archivedAt: null,
87104
settledOverride: null,
88105
pendingRuntimeRequest: null,
@@ -116,3 +133,73 @@ describe("limitRecoveryCommand arming", () => {
116133
assert.isNull(limitRecoveryCommand(thread, true, nowMs));
117134
});
118135
});
136+
137+
describe("usage-based recovery", () => {
138+
const reading = (checkedAt: number, usedPercent: number): ServerProviderUsageLimits => ({
139+
checkedAt: DateTime.formatIso(DateTime.makeUnsafe(checkedAt)),
140+
windows: [{ id: "weekly", label: "Weekly", kind: "weekly", usedPercent }],
141+
});
142+
const armedThread = (autoResume: boolean) => {
143+
const thread = failedThread(HOUR_MS, -48 * HOUR_MS);
144+
return {
145+
...thread,
146+
limitRecovery: {
147+
runId: thread.latestRunId!,
148+
resetAt: thread.usageLimitResetAt!,
149+
autoResume,
150+
snooze: true,
151+
requestId: CommandId.make("recovery:request"),
152+
},
153+
snoozedUntil: DateTime.makeUnsafe(thread.usageLimitResetAt!),
154+
};
155+
};
156+
it("resumes early with fresh headroom and preserves the continuation identity", () => {
157+
const thread = armedThread(true);
158+
const command = limitRecoveryCommand(thread, true, nowMs, true, reading(nowMs, 2));
159+
assert.equal(command?.type, "message.dispatch");
160+
if (command?.type !== "message.dispatch") return;
161+
assert.equal(command.text, "Continue where you left off.");
162+
const withoutCompletion = {
163+
...thread,
164+
latestRunCompletedAt: null,
165+
updatedAt: DateTime.makeUnsafe(nowMs + HOUR_MS),
166+
};
167+
assert.equal(
168+
limitRecoveryCommand(withoutCompletion, true, nowMs, true, reading(nowMs, 2))?.type,
169+
"message.dispatch",
170+
);
171+
172+
assert.equal(command.usageLimitContinuationOfRunId, thread.latestRunId);
173+
assert.equal(
174+
command.commandId,
175+
limitRecoveryCommand(
176+
{ ...thread, snoozedUntil: null },
177+
true,
178+
Date.parse(thread.usageLimitResetAt!),
179+
true,
180+
)?.commandId,
181+
);
182+
});
183+
it("releases a snooze-only recovery with fresh headroom", () => {
184+
const command = limitRecoveryCommand(armedThread(false), false, nowMs, true, reading(nowMs, 2));
185+
assert.equal(command?.type, "thread.metadata.update");
186+
if (command?.type !== "thread.metadata.update") return;
187+
assert.equal(command.limitRecovery?.snooze, false);
188+
assert.equal(command.limitRecovery?.autoResume, false);
189+
});
190+
it.each([true, false])(
191+
"keeps autoResume=%s blocked for stale or exhausted readings",
192+
(autoResume) => {
193+
const thread = armedThread(autoResume);
194+
for (const limits of [
195+
reading(nowMs - 2 * HOUR_MS, 2),
196+
reading(nowMs - HOUR_MS, 2),
197+
reading(nowMs, 100),
198+
reading(nowMs, 101),
199+
{ checkedAt: DateTime.formatIso(DateTime.makeUnsafe(nowMs)), windows: [] },
200+
]) {
201+
assert.isNull(limitRecoveryCommand(thread, autoResume, nowMs, true, limits));
202+
}
203+
},
204+
);
205+
});

0 commit comments

Comments
 (0)