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
5 changes: 4 additions & 1 deletion packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,10 @@ export type SessionLogOutput =
readonly type: "session.execution.interrupted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly reason: "user" | "shutdown" | "superseded" }
readonly data: {
readonly sessionID: Session.ID
readonly reason: "user" | "shutdown" | "superseded" | "inactivity"
}
}
| {
readonly id: Event.ID
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@ export type SessionExecutionInterrupted = {
type: "session.execution.interrupted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" }
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" | "inactivity" }
}

export type SessionInstructionsUpdated = {
Expand Down
52 changes: 37 additions & 15 deletions packages/core/src/location-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionExecution } from "./session/execution.js"
import { SessionStore } from "./session/store.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"

const isSessionEvent = Schema.is(SessionEvent.Durable)
Expand All @@ -18,9 +20,11 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const clock = yield* Clock.Clock
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const execution = yield* SessionExecution.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 }>()
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
const key = (ref: Location.Ref) => `${LocationServiceMap.canonical(ref).directory}\0${ref.workspaceID ?? ""}`
const touch = (ref: Location.Ref) =>
Effect.sync(() => {
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
Expand All @@ -39,26 +43,44 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
const cached = new Set(refs.map(key))
yield* Effect.forEach(
refs,
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
{ discard: true },
)
yield* Effect.forEach(refs, (ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)), { discard: true })
for (const id of entries.keys()) {
if (!cached.has(id)) entries.delete(id)
}
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))
yield* Effect.forEach(
expired,
(entry) => {
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 },
(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" },
)
}).pipe(Effect.forever, Effect.forkScoped)

Expand All @@ -70,5 +92,5 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [Bus.node, LocationServiceMap.node],
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
})
21 changes: 14 additions & 7 deletions packages/core/src/session/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,25 @@ 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.
* settlement matters. `awaitSettlement` waits only for the interrupted execution,
* rather than fresh work admitted during its cleanup.
*/
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
readonly interrupt: (
sessionID: SessionSchema.ID,
options?: {
readonly continue?: 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. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}

/** 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"
type InterruptReason = "user" | "shutdown" | "inactivity"

export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
Expand Down Expand Up @@ -120,9 +128,8 @@ export const layer = Layer.effect(
return
}
if (outcome.type === "interrupted") {
// A user cancel releases the claim: the turn must not resurrect at the next
// boot. Shutdown interruption keeps it for restart continuity.
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
// Deliberate stops release the claim; shutdown keeps it for restart continuity.
if (outcome.reason !== "shutdown") yield* jobs.cancel(sessionID)
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
Expand All @@ -147,7 +154,7 @@ export const layer = Layer.effect(
isActive: coordinator.isActive,
interrupt: (sessionID, options) =>
Effect.gen(function* () {
const interrupted = yield* coordinator.interrupt(sessionID, "user")
const interrupted = yield* coordinator.interrupt(sessionID, options?.reason ?? "user", options)
if (!options?.continue) 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: 25 additions & 3 deletions packages/core/src/session/run-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ 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. Compose with `awaitIdle` for settlement.
* interrupted. `awaitSettlement` waits for this execution's cleanup and settled hook,
* without following fresh work admitted during cleanup. `awaitIdle` follows successors too.
*/
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly awaitSettlement?: boolean },
) => 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 @@ -170,5 +175,22 @@ 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, awaitIdle }
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,
}
})
Loading
Loading