Skip to content
Merged
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
11 changes: 11 additions & 0 deletions packages/core/src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export interface Interface {
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
readonly pendingBackground: Effect.Effect<readonly Background[]>
readonly runningBackground: Effect.Effect<readonly Recovery[]>
readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect<void>
}

Expand Down Expand Up @@ -449,6 +450,15 @@ export const make = Effect.gen(function* () {
return recovered
}).pipe(Effect.withSpan("Job.pendingBackground"))

const runningBackground: Interface["runningBackground"] = SynchronizedRef.get(state.jobs).pipe(
Effect.map((jobs) =>
[...jobs.values()].flatMap((job) =>
job.isBackgrounded && job.info.status === "running" && job.recovery ? [job.recovery] : [],
),
),
Effect.withSpan("Job.runningBackground"),
)

const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
Effect.gen(function* () {
Expand All @@ -471,6 +481,7 @@ export const make = Effect.gen(function* () {
backgroundAll,
cancel,
pendingBackground,
runningBackground,
completeBackground,
})
})
Expand Down
54 changes: 24 additions & 30 deletions packages/core/src/location-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * as LocationActivity from "./location-activity.js"

import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect"
import { Bus } from "./bus.js"
import { Job } from "./job.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
Expand All @@ -21,6 +22,7 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const execution = yield* SessionExecution.Service
const jobs = yield* Job.Service
const sessions = yield* SessionStore.Service
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
Expand Down Expand Up @@ -50,37 +52,29 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const now = clock.currentTimeMillisUnsafe()
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
if (expired.length === 0) return
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
// Waiting on a question, a long tool call, or a background job emits no durable activity.
const owners = [
...(yield* execution.active),
...(yield* jobs.runningBackground).flatMap((recovery) =>
recovery.kind === "shell" ? [recovery.sessionID] : [recovery.childSessionID, recovery.parentSessionID],
),
]
const occupied = new Set(
(yield* Effect.forEach(owners, (sessionID) => sessions.get(sessionID))).flatMap((session) =>
session ? [key(session.location)] : [],
),
)
yield* Effect.forEach(
expired,
(entry) =>
Effect.gen(function* () {
const owners = active.flatMap((session) =>
session && key(session.location) === key(entry.ref) ? [session] : [],
)
// Invalidation only detaches the cache entry; borrowers retain the old
// graph. Stop its executions and settle tool cleanup before detaching it.
yield* Effect.forEach(
owners,
(session) => execution.interrupt(session.id, { reason: "inactivity", awaitSettlement: true }),
{
discard: true,
concurrency: "unbounded",
},
)
const remaining = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
// New work admitted during cleanup may now own the cached graph.
if (remaining.some((session) => session && key(session.location) === key(entry.ref))) {
yield* touch(entry.ref)
return
}
entries.delete(key(entry.ref))
yield* Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
}),
{ discard: true, concurrency: "unbounded" },
(entry) => {
if (occupied.has(key(entry.ref))) return touch(entry.ref)
entries.delete(key(entry.ref))
return Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
},
{ discard: true },
)
}).pipe(Effect.forever, Effect.forkScoped)

Expand All @@ -92,5 +86,5 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
deps: [Bus.node, Job.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
})
9 changes: 3 additions & 6 deletions packages/core/src/session/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,12 @@ export interface Interface {
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
* settlement matters. `awaitSettlement` waits only for the interrupted execution,
* rather than fresh work admitted during its cleanup.
* settlement matters.
*/
readonly interrupt: (
sessionID: SessionSchema.ID,
options?: {
readonly resume?: boolean
readonly reason?: "user" | "inactivity"
readonly awaitSettlement?: boolean
},
) => Effect.Effect<boolean>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
Expand All @@ -45,7 +42,7 @@ export interface Interface {
/** Routes execution from a Session ID to its selected instance's runner. */
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}

type InterruptReason = "user" | "shutdown" | "inactivity"
type InterruptReason = "user" | "shutdown"

export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
Expand Down Expand Up @@ -152,7 +149,7 @@ export const layer = Layer.effect(
isActive: coordinator.isActive,
interrupt: (sessionID, options) =>
Effect.gen(function* () {
const interrupted = yield* coordinator.interrupt(sessionID, options?.reason ?? "user", options)
const interrupted = yield* coordinator.interrupt(sessionID, "user")
if (!options?.resume) return interrupted
// Resume steering input and between-turn control work from the interrupted
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
Expand Down
28 changes: 3 additions & 25 deletions packages/core/src/session/run-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,9 @@ export interface Coordinator<Key, E, Reason = never> {
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
* finalizers and settled hook on its own time. Returns whether an active execution was
* interrupted. `awaitSettlement` waits for this execution's cleanup and settled hook,
* without following fresh work admitted during cleanup. `awaitIdle` follows successors too.
* interrupted.
*/
readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly awaitSettlement?: boolean },
) => Effect.Effect<boolean>
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
Expand Down Expand Up @@ -175,22 +170,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
})

return {
active: Effect.sync(() => new Set(executions.keys())),
isActive,
run,
wake,
interrupt: (key, reason, options) =>
Effect.suspend(() => {
const execution = executions.get(key)
return interrupt(key, reason).pipe(
Effect.tap(() =>
options?.awaitSettlement && execution
? Deferred.await(execution.done).pipe(Effect.ignoreCause)
: Effect.void,
),
)
}),
awaitIdle,
}
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
})
3 changes: 2 additions & 1 deletion packages/core/test/browser-idle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Global } from "@opencode/util/global"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { Location } from "@opencode/core/location"
import { Job } from "@opencode/core/job"
import { LocationActivity } from "@opencode/core/location-activity"
import { LocationServiceMap } from "@opencode/core/location-services"
import { Plugin } from "@opencode/core/plugin"
Expand Down Expand Up @@ -49,7 +50,7 @@ const it = testEffect(
layer: LocationActivity.layer({ timeToLive, sweepInterval: "100 millis" }).pipe(
Layer.provide(Layer.succeed(Clock.Clock, activityClock)),
),
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
deps: [Bus.node, Job.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
}),
),
],
Expand Down
Loading
Loading