Skip to content

Commit 7329415

Browse files
bobbyjohnstxclaude
andcommitted
feat: LRU instance eviction + max concurrent sessions (#43, #40)
Add LRU eviction to InstanceStore — tracks access order and evicts least-recently-used instances when cache exceeds max_instances (default 32, configurable via TINYCODE_MAX_INSTANCES env var). Evicted instances are properly disposed (fibers, watchers, LSP connections cleaned up). Add max concurrent session enforcement — when TINYCODE_MAX_SESSIONS is set, ensureRunning() checks active session count before starting a new processor loop. Excess sessions are rejected with BusyError. Add server config schema fields: max_instances and max_sessions. Closes #43, closes #40. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0f61141 commit 7329415

3 files changed

Lines changed: 50 additions & 1 deletion

File tree

‎packages/tinycode/src/config/server.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export const Server = Schema.Struct({
1313
cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
1414
description: "Additional domains to allow for CORS",
1515
}),
16+
max_instances: Schema.optional(PositiveInt).annotate({
17+
description: "Maximum cached project instances. Least-recently-used instances are evicted when exceeded. Default: 32.",
18+
}),
19+
max_sessions: Schema.optional(PositiveInt).annotate({
20+
description: "Maximum concurrent active sessions (busy/processing). New session prompts are rejected when exceeded. Default: unlimited.",
21+
}),
1622
}).annotate({ identifier: "ServerConfig" })
1723
export type Server = Schema.Schema.Type<typeof Server>
1824

‎packages/tinycode/src/project/instance-store.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,38 @@ interface Entry {
3131
readonly deferred: Deferred.Deferred<InstanceContext>
3232
}
3333

34+
const DEFAULT_MAX_INSTANCES = 32
35+
const MAX_INSTANCES = parseInt(process.env["TINYCODE_MAX_INSTANCES"] ?? "", 10) || DEFAULT_MAX_INSTANCES
36+
3437
export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
3538
Service,
3639
Effect.gen(function* () {
3740
const project = yield* Project.Service
3841
const bootstrap = yield* InstanceBootstrap.Service
3942
const scope = yield* Scope.Scope
4043
const cache = new Map<string, Entry>()
44+
const accessOrder: string[] = []
45+
let maxInstances = MAX_INSTANCES
46+
47+
const touchAccess = (directory: string) => {
48+
const idx = accessOrder.indexOf(directory)
49+
if (idx !== -1) accessOrder.splice(idx, 1)
50+
accessOrder.push(directory)
51+
}
52+
53+
const evictLRU = Effect.fn("InstanceStore.evictLRU")(function* () {
54+
while (cache.size > maxInstances && accessOrder.length > 0) {
55+
const oldest = accessOrder.shift()!
56+
const entry = cache.get(oldest)
57+
if (!entry) continue
58+
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
59+
if (Exit.isSuccess(exit)) {
60+
yield* disposeEntry(oldest, entry, exit.value)
61+
} else {
62+
yield* removeEntry(oldest, entry)
63+
}
64+
}
65+
})
4166

4267
const boot = (input: LoadInput & { directory: string }) =>
4368
Effect.gen(function* () {
@@ -63,6 +88,8 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
6388
Effect.sync(() => {
6489
if (cache.get(directory) !== entry) return false
6590
cache.delete(directory)
91+
const idx = accessOrder.indexOf(directory)
92+
if (idx !== -1) accessOrder.splice(idx, 1)
6693
return true
6794
})
6895

@@ -107,10 +134,15 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
107134
return Effect.uninterruptibleMask((restore) =>
108135
Effect.gen(function* () {
109136
const existing = cache.get(directory)
110-
if (existing) return yield* restore(Deferred.await(existing.deferred))
137+
if (existing) {
138+
touchAccess(directory)
139+
return yield* restore(Deferred.await(existing.deferred))
140+
}
111141

112142
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
113143
cache.set(directory, entry)
144+
touchAccess(directory)
145+
yield* evictLRU()
114146
yield* Effect.gen(function* () {
115147
yield* Effect.logInfo("creating instance").pipe(Effect.annotateLogs("directory", directory))
116148
yield* completeLoad(directory, input, entry)

‎packages/tinycode/src/session/run-state.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { MessageV2 } from "./message-v2"
77
import { SessionID } from "./schema"
88
import { SessionStatus } from "./status"
99

10+
const MAX_SESSIONS = parseInt(process.env["TINYCODE_MAX_SESSIONS"] ?? "", 10) || 0
11+
1012
export interface Interface {
1113
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError>
1214
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
@@ -89,6 +91,15 @@ export const layer = Layer.effect(
8991
onInterrupt: Effect.Effect<MessageV2.WithParts>,
9092
work: Effect.Effect<MessageV2.WithParts>,
9193
) {
94+
if (MAX_SESSIONS > 0) {
95+
const active = yield* status.list()
96+
if (active.size >= MAX_SESSIONS && !active.has(sessionID)) {
97+
yield* Effect.logWarning("max concurrent sessions reached").pipe(
98+
Effect.annotateLogs({ sessionID, active: active.size, max: MAX_SESSIONS }),
99+
)
100+
return yield* Effect.die(busyError(sessionID))
101+
}
102+
}
92103
return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
93104
})
94105

0 commit comments

Comments
 (0)