Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2299,6 +2299,7 @@ export type ConfigEntry =
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
worktree?: ConfigWorktree
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
storage?: { sessions?: string }
providers?: {
[x: string]: {
canonical?: ProviderID
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export function normalize(input: unknown): Result {
websearch: Info.fields.websearch,
worktree: Info.fields.worktree,
warming: Info.fields.warming,
storage: Info.fields.storage,
}
Object.entries(nativeAtomic).forEach(([key, schema]) => {
if (!own(input, key)) return
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/session/expiry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
export * as SessionExpiry from "./expiry.js"

import path from "path"
import { and, asc, isNull, lt } from "drizzle-orm"
import { parse, type ParseError } from "jsonc-parser"
import { Clock, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Database } from "../database/database.js"
import { Job } from "../job.js"
import { Session } from "../session.js"
import { SessionExecution } from "./execution.js"
import { SessionTable } from "./sql.js"

const SWEEP_INTERVAL = Duration.hours(1)

const decodeSessions = Schema.decodeUnknownOption(Schema.DurationFromString)

/** Projects `storage.sessions` from one global config file, like the CLI updater's `update` policy. */
export function decodeMaxAge(text: string) {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof input !== "object" || input === null || !("storage" in input)) return
const storage = input.storage
if (typeof storage !== "object" || storage === null || !("sessions" in storage)) return
return Option.getOrUndefined(decodeSessions(storage.sessions))
}

/**
* Reads `storage.sessions` from the global config directory only. Project config is never read,
* so a project cannot delete another project's Sessions. Later files win, as with `update`.
*/
export const configured = Effect.fn("SessionExpiry.configured")(function* () {
const global = yield* Global.Service
const fs = yield* FSUtil.Service
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs.readFileStringSafe(path.join(global.config, name)).pipe(
Effect.map((text) => (text === undefined ? undefined : decodeMaxAge(text))),
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined)
})

/**
* Deletes top-level Sessions, with their children, whose last activity is older than `maxAge`.
* Returns the deleted Session count.
*/
export const expire = Effect.fn("SessionExpiry.expire")(function* (maxAge: Duration.Duration) {
const { db } = yield* Database.Service
const execution = yield* SessionExecution.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(maxAge)
// Suspended Sessions belong to restart recovery, which resumes or terminalizes them.
const expired = yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(
and(isNull(SessionTable.parent_id), isNull(SessionTable.time_suspended), lt(SessionTable.time_updated, cutoff)),
)
.orderBy(asc(SessionTable.time_updated))
.all()
.pipe(Effect.orDie)
if (expired.length === 0) return 0
// Waiting on a question, a long tool call, or a background job emits no activity.
const occupied = new Set([
...(yield* execution.active),
...(yield* jobs.runningBackground).flatMap((recovery) =>
recovery.kind === "shell" ? [recovery.sessionID] : [recovery.childSessionID, recovery.parentSessionID],
),
])
const removable = expired.filter((row) => !occupied.has(row.id))
yield* Effect.forEach(
removable,
(row) => sessions.remove(row.id).pipe(Effect.catchTag("Session.NotFoundError", () => Effect.void)),
{ discard: true },
)
return removable.length
})

/** Applies the global `storage.sessions` config. Unset keeps every Session. */
export const sweep = Effect.fn("SessionExpiry.sweep")(function* () {
const maxAge = yield* configured()
if (maxAge === undefined) return
const millis = Duration.toMillis(maxAge)
if (!Number.isFinite(millis) || millis <= 0) {
yield* Effect.logWarning("storage.sessions must be a finite positive duration")
return
}
const count = yield* expire(maxAge)
if (count === 0) return
yield* Effect.logInfo("deleted expired sessions", { count, maxAge: Duration.format(maxAge) })
})

export const node = makeGlobalNode({
name: "session-expiry-sweep",
layer: Layer.effectDiscard(
sweep().pipe(
Effect.ignoreCause({ log: "Error", message: "Session expiry sweep failed" }),
Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)),
Effect.forkScoped,
),
),
deps: [Database.node, FSUtil.node, Global.node, Job.node, Session.node, SessionExecution.node],
})
7 changes: 7 additions & 0 deletions packages/core/test/config/normalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ describe("ConfigNormalize", () => {
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
})

test("keeps storage durations", () => {
const result = normalized({ storage: { sessions: "30 days" } })
expect(result.encoded.storage).toEqual({ sessions: "2592000000 millis" })
const info = Schema.decodeUnknownSync(Info)(result.encoded)
expect(Duration.toMillis(info.storage?.sessions ?? Duration.zero)).toBe(2_592_000_000)
})

test("preserves arbitrary JSON-round-tripped native configuration", () => {
const result = Effect.runSync(
Arbitrary.checkEffect(
Expand Down
123 changes: 123 additions & 0 deletions packages/core/test/session-expiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import fs from "fs"
import os from "os"
import path from "path"
import { afterAll, describe, expect } from "bun:test"
import { Clock, Duration, Effect } from "effect"
import { eq } from "drizzle-orm"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Job } from "@opencode/core/job"
import { Location } from "@opencode/core/location"
import { Project } from "@opencode/core/project"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionProjector } from "@opencode/core/session/projector"
import { SessionExpiry } from "@opencode/core/session/expiry"
import { SessionTable } from "@opencode/core/session/sql"
import { SessionStore } from "@opencode/core/session/store"
import { Global } from "@opencode/util/global"
import { FSUtil } from "@opencode/util/fs-util"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"

const globalDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "opencode-session-expiry-")))
afterAll(() => fs.rmSync(globalDirectory, { recursive: true, force: true }))

const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
Session.node,
SessionExecution.node,
Job.node,
Global.node,
FSUtil.node,
]),
[
Project.node.replace(globalProjectNode),
SessionExecution.node.replace(SessionExecution.noopLayer),
Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })),
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const day = Duration.toMillis(Duration.days(1))

const age = Effect.fn(function* (sessionID: Session.ID, days: number) {
const { db } = yield* Database.Service
const now = yield* Clock.currentTimeMillis
yield* db
.update(SessionTable)
.set({ time_updated: now - days * day })
.where(eq(SessionTable.id, sessionID))
.run()
})

const exists = (sessionID: Session.ID) =>
Effect.gen(function* () {
const session = yield* Session.Service
return yield* session.get(sessionID).pipe(
Effect.as(true),
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(false)),
)
})

describe("SessionExpiry", () => {
it.effect("deletes top-level sessions and their children past their max age", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const old = yield* session.create({ location })
const child = yield* session.create({ parentID: old.id })
const recent = yield* session.create({ location })
yield* age(old.id, 40)
// A child's own age never matters; it lives and dies with its top-level Session.
yield* age(child.id, 1)
yield* age(recent.id, 10)

expect(yield* SessionExpiry.expire(Duration.days(30))).toBe(1)
expect(yield* exists(old.id)).toBe(false)
expect(yield* exists(child.id)).toBe(false)
expect(yield* exists(recent.id)).toBe(true)
}),
)

it.effect("keeps suspended sessions for restart recovery", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const store = yield* SessionStore.Service
const suspended = yield* session.create({ location })
yield* store.claim(suspended.id)
yield* age(suspended.id, 40)

expect(yield* SessionExpiry.expire(Duration.days(30))).toBe(0)
expect(yield* exists(suspended.id)).toBe(true)
}),
)

it.effect("reads storage.sessions from global config and keeps sessions when unset", () =>
Effect.gen(function* () {
const file = path.join(globalDirectory, "opencode.json")
fs.rmSync(file, { force: true })
expect(yield* SessionExpiry.configured()).toBeUndefined()

fs.writeFileSync(file, JSON.stringify({ storage: { sessions: "30 days" } }))
expect(Duration.toMillis((yield* SessionExpiry.configured())!)).toBe(Duration.toMillis(Duration.days(30)))
fs.rmSync(file)
}),
)

it.effect("decodes storage.sessions from JSONC and ignores invalid values", () =>
Effect.sync(() => {
expect(Duration.toMillis(SessionExpiry.decodeMaxAge('{ "storage": { "sessions": "7 days", }, }')!)).toBe(7 * day)
expect(SessionExpiry.decodeMaxAge('{ "storage": {} }')).toBeUndefined()
expect(SessionExpiry.decodeMaxAge('{ "storage": { "sessions": "soon" } }')).toBeUndefined()
expect(SessionExpiry.decodeMaxAge("{ broken")).toBeUndefined()
}),
)
})
9 changes: 9 additions & 0 deletions packages/protocol/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -14448,6 +14448,15 @@
}
]
},
"storage": {
"type": "object",
"properties": {
"sessions": {
"type": "string"
}
},
"additionalProperties": false
},
"providers": {
"type": "object",
"additionalProperties": {
Expand Down
4 changes: 4 additions & 0 deletions packages/schema/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ConfigPlugin } from "./config/plugin.js"
import { ConfigPolicy } from "./config/policy.js"
import { ConfigProvider } from "./config/provider.js"
import { ConfigReference } from "./config/reference.js"
import { ConfigStorage } from "./config/storage.js"
import { ConfigWebSearch } from "./config/websearch.js"
import { ConfigToolOutput } from "./config/tool-output.js"
import { ConfigWatcher } from "./config/watcher.js"
Expand Down Expand Up @@ -109,6 +110,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
warming: ConfigWarming.Warming.pipe(optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
storage: ConfigStorage.Info.pipe(optional).annotate({
description: "Automatic cleanup of locally stored data",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
experimental: ConfigExperimental.Info.pipe(optional),
}) {}
Expand Down
11 changes: 11 additions & 0 deletions packages/schema/src/config/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export * as ConfigStorage from "./storage.js"

import { Schema } from "effect"
import { optional } from "../schema.js"

export class Info extends Schema.Class<Info>("Config.Storage")({
sessions: Schema.DurationFromString.pipe(optional).annotate({
description:
'Delete sessions whose last activity is older than this duration, such as "30 days". Read from global config only. Unset keeps sessions forever',
}),
}) {}
2 changes: 2 additions & 0 deletions packages/server/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { LocationServiceMap } from "@opencode/core/location-service-map"
import { LocationActivity } from "@opencode/core/location-activity"
import { ModelsDev } from "@opencode/core/models-dev"
import { SessionRestart } from "@opencode/core/session/execution/restart"
import { SessionExpiry } from "@opencode/core/session/expiry"
import { PluginUpdate } from "@opencode/core/plugin/update"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { WellKnown } from "@opencode/core/wellknown"
Expand Down Expand Up @@ -74,6 +75,7 @@ const applicationServiceNodes = [
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
SessionExpiry.node,
Workspace.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)
Expand Down
9 changes: 9 additions & 0 deletions services/www/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -14448,6 +14448,15 @@
}
]
},
"storage": {
"type": "object",
"properties": {
"sessions": {
"type": "string"
}
},
"additionalProperties": false
},
"providers": {
"type": "object",
"additionalProperties": {
Expand Down
9 changes: 9 additions & 0 deletions services/www/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -14448,6 +14448,15 @@
}
]
},
"storage": {
"type": "object",
"properties": {
"sessions": {
"type": "string"
}
},
"additionalProperties": false
},
"providers": {
"type": "object",
"additionalProperties": {
Expand Down
19 changes: 19 additions & 0 deletions services/www/src/docs/content/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,25 @@ interval and 30-minute active window.
See the [warming guide](/warming) for request behavior, customization,
and cost considerations.

### Storage

OpenCode keeps sessions forever by default. Set `storage.sessions` to a
duration to delete sessions whose last activity is older than it. Subagent
sessions are deleted with the session that started them. OpenCode checks once
an hour and skips sessions that are still running.

```jsonc
{
"storage": {
"sessions": "30 days",
},
}
```

Deleted sessions can't be recovered. Set this in the global config
directory; project-level values are ignored, so a project's `opencode.json`
can't delete sessions from other projects.

### Skills

Add directories or URLs that OpenCode should search for agent skills.
Expand Down
Loading