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
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 @@ -1900,7 +1900,7 @@ export type FormField =
export type FormState =
| { status: "pending" }
| { status: "answered"; answer: FormAnswer }
| { status: "cancelled"; message?: string }
| { status: "cancelled"; message?: string; cause?: "evicted" }

export type CredentialKey = {
type: "key"
Expand Down
59 changes: 46 additions & 13 deletions packages/core/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ import { Bus } from "./bus.js"

const RETENTION = Duration.minutes(10)

// Client-facing close messages must stay bounded for UI surfaces.
const CLOSE_MESSAGE_CAP = 160

function truncate(text: string, cap: number): string {
return text.length > cap ? `${text.slice(0, cap)}…` : text
}

// Identifies the pending content the way toField filled it: the field's question
// text when present, else its title, else its key.
function describePendingContent(form: Info): string {
return form.fields.map((field) => `"${field.description ?? field.title ?? field.key}"`).join("; ")
}

export const ID = Form.ID
export type ID = typeof ID.Type

Expand Down Expand Up @@ -76,14 +89,21 @@ export interface ReplyInput {

export interface CancelOptions {
readonly message?: string
/** Why the form is being cancelled by the close path; absent for caller-initiated cancels. */
readonly cause?: "evicted"
}

export interface CloseOptions {
/** Why the close path is cancelling pending forms; absent for teardown without a known cause. */
readonly cause?: "evicted"
}

export interface ListInput {
readonly sessionID?: Form.Info["sessionID"]
}

export interface Interface {
readonly close: Effect.Effect<void>
readonly close: (options?: CloseOptions) => Effect.Effect<void>
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
Expand Down Expand Up @@ -204,6 +224,7 @@ export const layer = Layer.effect(
const next: TerminalState = {
status: "cancelled",
...(options?.message === undefined ? {} : { message: options.message }),
...(options?.cause === undefined ? {} : { cause: options.cause }),
}
yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID })
yield* Cache.set(forms, id, { ...entry, state: next })
Expand All @@ -212,19 +233,31 @@ export const layer = Layer.effect(
),
)

const close = Effect.sync(() => {
closed = true
}).pipe(
Effect.andThen(Cache.values(forms)),
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
const close = (options?: CloseOptions) =>
Effect.sync(() => {
closed = true
}).pipe(
Effect.andThen(Cache.values(forms)),
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) =>
cancel(entry.form.id, {
...(options?.cause === undefined
? {}
: {
cause: options.cause,
message: truncate(
`The location was evicted for inactivity before this form received a reply ("${entry.form.title}": ${describePendingContent(entry.form)})`,
CLOSE_MESSAGE_CAP,
),
}),
}).pipe(Effect.ignore),
{ discard: true },
),
),
),
)
yield* Effect.addFinalizer(() => close)
)
yield* Effect.addFinalizer(() => close())

return Service.of({ create, ask, get, list, state, reply, cancel, close })
}),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
// Instance boot failures are defects; provided operations retain their typed errors.
Layer.orDie,
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown())),
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/location-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
yield* Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
}).pipe(Effect.andThen(locations.invalidate(entry.ref, { cause: "evicted" })))
}),
{ discard: true, concurrency: "unbounded" },
)
Expand Down
37 changes: 22 additions & 15 deletions packages/core/src/location-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ const isSessionEvent = Schema.is(SessionEvent.Durable)

export class Service extends Context.Service<
Service,
{ readonly isClosed: () => boolean; readonly shutdown: Effect.Effect<void> }
{
readonly isClosed: () => boolean
readonly shutdown: (options?: Form.CloseOptions) => Effect.Effect<void>
}
>()("@opencode/LocationLifecycle") {}

const layer = Layer.effect(
Expand All @@ -39,21 +42,25 @@ const layer = Layer.effect(
})
yield* Effect.addFinalizer(() => unsubscribe)
let closed = false
const shutdown = yield* Effect.cached(
Effect.gen(function* () {
// The close happens once: the first invocation fixes the cause. The instance
// finalizer and the invalidate close hook share this guard.
const shutdown = (options?: Form.CloseOptions) =>
Effect.suspend(() => {
if (closed) return Effect.void
closed = true
yield* permission.close
yield* forms.close
yield* rpc.close
yield* bus.publish(
LocationEvent.Shutdown,
{},
{
location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
},
)
}).pipe(Effect.uninterruptible),
)
return Effect.gen(function* () {
yield* permission.close
yield* forms.close(options)
yield* rpc.close
yield* bus.publish(
LocationEvent.Shutdown,
{},
{
location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
},
)
}).pipe(Effect.uninterruptible)
})
return Service.of({
isClosed: () => closed,
shutdown,
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/location-service-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { Node } from "@opencode/util/effect/app-node"
import { AbsolutePath } from "@opencode/schema/schema"
import path from "path"
import type { Form } from "./form.js"
import { Location } from "./location.js"
import type { Instance } from "./instance.js"

export class Service extends Context.Service<
Service,
LayerMap.LayerMap<Location.Ref, Instance.Services, Instance.Error>
LayerMap.LayerMap<Location.Ref, Instance.Services, Instance.Error> & {
/** Invalidate with an optional close cause, forwarded to the entry's close hook. */
readonly invalidate: (ref: Location.Ref, options?: Form.CloseOptions) => Effect.Effect<void>
}
>()("@opencode/example/LocationServiceMap") {
static get(ref: Location.Ref) {
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
Expand Down
28 changes: 23 additions & 5 deletions packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Context, Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Form } from "./form.js"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
Expand All @@ -9,17 +10,24 @@ export { LocationServiceMap } from "./location-service-map.js"

export type LocationServices = Instance.Services

type BuildRecord = {
/** Cancels the entry's pending forms, carrying the invalidate cause (the scope finalizer cannot). */
closeForms?: (options?: Form.CloseOptions) => Effect.Effect<void>
/** The entry's lifecycle shutdown, invoked after routing detach as today. */
close?: (options?: Form.CloseOptions) => Effect.Effect<void>
}

export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
return Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const owner = yield* Effect.scope
const builds = MutableHashMap.empty<Location.Ref, { close?: Effect.Effect<void> }>()
const builds = MutableHashMap.empty<Location.Ref, BuildRecord>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
(ref: Location.Ref) => {
const build: { close?: Effect.Effect<void> } = {}
const build: BuildRecord = {}
MutableHashMap.set(builds, ref, build)
return Layer.fromBuild((memoMap, scope) =>
Effect.suspend(() =>
Expand All @@ -32,8 +40,9 @@ export function buildLocationServiceMap(
const lifecycle = Context.get(exit.value, LocationLifecycle.Service)
// A boot detached while in flight still needs shutdown and cancellation.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build)
return yield* lifecycle.shutdown
return yield* lifecycle.shutdown()
build.close = lifecycle.shutdown
build.closeForms = (options) => Context.get(exit.value, Form.Service).close(options)
})
}
// An explicitly invalidated build must not evict its replacement.
Expand All @@ -60,15 +69,24 @@ export function buildLocationServiceMap(
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
invalidate: (ref: Location.Ref) =>
invalidate: (ref: Location.Ref, options?: Form.CloseOptions) =>
Effect.suspend(() => {
const key = LocationServiceMap.canonical(ref)
const build = Option.getOrUndefined(MutableHashMap.get(builds, key))
MutableHashMap.remove(builds, key)
if (options?.cause !== undefined)
// A cause-carrying invalidate must deliver the cause to the pending forms'
// cancellation: with no borrowers, inner.invalidate closes the entry scope
// synchronously, and the scope finalizer's close cannot carry a cause. Cancel
// the forms with the cause before detaching; the shutdown that follows (its
// own forms close included) finds them settled.
return (build?.closeForms?.(options) ?? Effect.void).pipe(
Effect.andThen(() => inner.invalidate(key).pipe(Effect.andThen(build?.close?.(options) ?? Effect.void))),
)
// Detach routing first, then cancel interactions and notify clients. Running
// steps retain their borrowed graph until they can hand off at a boundary.
// Do not await a boot here: failed/in-flight builds have their own cleanup path.
return inner.invalidate(key).pipe(Effect.andThen(build?.close ?? Effect.void))
return inner.invalidate(key).pipe(Effect.andThen(build?.close?.(options) ?? Effect.void))
}).pipe(Effect.uninterruptible),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/session/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ 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"
/** Causes the coordinator records for `interrupt`; published verbatim on the interrupted event. */
export 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 @@ -93,7 +94,13 @@ export const layer = Layer.effect(
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation, promotable }),
runner.drain({
sessionID,
force,
continuation,
promotable,
interruptionReason: coordinator.interruptionReason(sessionID),
}),
).pipe(
instances.provide(session),
Effect.tapCause((cause) =>
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/session/run-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface Coordinator<Key, E, Reason = never> {
reason?: Reason,
options?: { readonly awaitSettlement?: boolean },
) => Effect.Effect<boolean>
/** Reads the recorded interruption cause for the current execution. Undefined when idle or never interrupted. */
readonly interruptionReason: (key: Key) => Effect.Effect<Reason | undefined>
/** 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 @@ -166,6 +168,10 @@ export const make = <Key, E, Reason = never>(options: {
return true
})

// The record stays in the map until settle, so drains can read the cause of their own
// interruption while they settle (the settled hook reads the field directly instead).
const interruptionReason = (key: Key) => Effect.sync(() => executions.get(key)?.interruptionReason)

// One execution's `done` already spans coalesced continuations; re-check after it
// settles to cover a successor execution started by a late doorbell.
const awaitIdle = (key: Key): Effect.Effect<void> =>
Expand All @@ -180,6 +186,7 @@ export const make = <Key, E, Reason = never>(options: {
isActive,
run,
wake,
interruptionReason,
interrupt: (key, reason, options) =>
Effect.suspend(() => {
const execution = executions.get(key)
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/session/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Context, Data, Effect } from "effect"
import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError } from "../error.js"
import type { InterruptReason } from "../execution.js"
import { SessionRunnerModel } from "./model.js"
import type { Instructions } from "../../instructions/index.js"

Expand Down Expand Up @@ -34,6 +35,8 @@ export interface Interface {
readonly continuation?: Continuation
/** "steer" settles the active intent without promoting queued next-turn work. */
readonly promotable?: Promotable
/** Reads the caller's recorded interruption cause; absent for direct drains with no coordinator. */
readonly interruptionReason?: Effect.Effect<InterruptReason | undefined>
}) => Effect.Effect<DrainResult, RunError>
}

Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { LocationLifecycle } from "../../location-lifecycle.js"
import type { InterruptReason } from "../execution.js"
import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
Expand Down Expand Up @@ -58,6 +59,8 @@ const layer = Layer.effect(
let step = input.continuation?.step ?? 1
let entering = true
const promotable = input.promotable ?? "input"
// Cause reads happen at settlement time; the accessor is bound per drain call.
const interruptionReason = input.interruptionReason ?? Effect.succeed(undefined)
if (!force && !continuing) {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (!pending) return DrainResult.Complete()
Expand Down Expand Up @@ -188,7 +191,7 @@ const layer = Layer.effect(
while (true) {
const next = yield* advanceToStep()
if (next._tag !== "Ready") return next
continuing = yield* runStep(next.context, step)
continuing = yield* runStep(next.context, step, interruptionReason)
step++
force = false
entering = false
Expand All @@ -203,7 +206,11 @@ const layer = Layer.effect(
})

/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const runStep = Effect.fn("SessionRunner.runStep")(function* (
first: SessionContext.Loaded,
step: number,
interruptionReason: Effect.Effect<InterruptReason | undefined>,
) {
const sessionID = first.session.id
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* SessionRunnerRetry.make(bus, sessionID)
Expand Down Expand Up @@ -244,6 +251,8 @@ const layer = Layer.effect(
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
// Read at settlement time inside the attempt: the interrupt lands mid-step.
interruptionReason,
sessionID,
assistantMessageID,
agent: loaded.agent.id,
Expand Down
Loading
Loading