Merge branch 'dev' into feat/tui-diff-open-keybind

This commit is contained in:
Victor Navarro
2026-06-22 22:39:07 +02:00
committed by GitHub
59 changed files with 1151 additions and 2458 deletions
+6 -9
View File
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
_Avoid_: System update, system notification, raw text diff
**Context Epoch**:
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**.
@@ -75,31 +75,28 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
@@ -196,6 +196,13 @@ export async function handler(
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip)
if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
headers.set(k, v)
})
headers.delete("host")
headers.delete("content-length")
headers.delete("x-opencode-request")
+1
View File
@@ -53,6 +53,7 @@ export namespace ZenData {
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
headerModifier: z.record(z.string(), z.any()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(),
+2 -32
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"ddl": [
{
"name": "workspace",
@@ -900,16 +900,6 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'build'",
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
@@ -930,26 +920,6 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "replacement_seq",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": false,
+2
View File
@@ -37,5 +37,7 @@ export const migrations = (
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622170816_reset_v2_session_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
-3
View File
@@ -149,11 +149,8 @@ export default {
CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`agent\` text DEFAULT 'build' NOT NULL,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
+11 -64
View File
@@ -1,11 +1,9 @@
export * as OpenCode from "./opencode"
import { Context, Effect, Layer } from "effect"
import { Catalog } from "../catalog"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LocationServiceMap } from "../location-layer"
import { PluginBoot } from "../plugin/boot"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import * as SessionExecutionLocal from "../session/execution/local"
@@ -23,69 +21,22 @@ export interface Interface {
/** Intentional public native API for Effect applications embedding OpenCode. */
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
class SessionModelValidation extends Context.Service<
SessionModelValidation,
{
readonly validate: (
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
}
>()("@opencode/public/OpenCode/SessionModelValidation") {}
const ApplicationToolsLayer = ApplicationTools.layer
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
const SessionModelValidationLayer = Layer.effect(
SessionModelValidation,
Effect.gen(function* () {
const locations = yield* LocationServiceMap
return SessionModelValidation.of({
validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) {
yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
const catalog = yield* Catalog.Service
const model = (yield* catalog.model.available()).find(
(model) => model.providerID === input.model.providerID && model.id === input.model.id,
)
if (!model)
return yield* new Session.ModelUnavailableError({
providerID: input.model.providerID,
modelID: input.model.id,
})
if (
input.model.variant !== undefined &&
input.model.variant !== "default" &&
!model.variants.some((variant) => variant.id === input.model.variant)
)
return yield* new Session.VariantUnavailableError({
providerID: input.model.providerID,
modelID: input.model.id,
variant: input.model.variant,
})
}).pipe(Effect.provide(locations.get(input.location)))
}),
})
}),
const SessionsLayer = SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
Layer.orDie,
)
const SessionsLayer = Layer.merge(
SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
),
SessionModelValidationLayer,
).pipe(Layer.provide(LocationServicesLayer))
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation
return Service.of({
tools: { register: tools.register },
sessions: {
@@ -98,11 +49,7 @@ export const layer = Layer.effect(
}),
get: sessions.get,
list: sessions.list,
switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
const session = yield* sessions.get(input.sessionID)
yield* validation.validate({ ...input, location: session.location })
yield* sessions.switchModel(input)
}),
switchModel: sessions.switchModel,
interrupt: sessions.interrupt,
prompt: (input) =>
sessions.prompt({
@@ -124,6 +71,6 @@ export const layer = Layer.effect(
},
})
}),
).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer)))
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.
+2 -22
View File
@@ -1,7 +1,6 @@
export * as Session from "./session"
import { Effect, Schema, Stream } from "effect"
import { ModelV2 } from "../model"
import { Effect, Stream } from "effect"
import { SessionV2 } from "../session"
import { MessageDecodeError } from "../session/error"
import { SessionEvent } from "../session/event"
@@ -41,23 +40,6 @@ export type NotFoundError = SessionV2.NotFoundError
export const PromptConflictError = SessionV2.PromptConflictError
export type PromptConflictError = SessionV2.PromptConflictError
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"Session.ModelUnavailableError",
{
providerID: Model.Ref.fields.providerID,
modelID: Model.Ref.fields.id,
},
) {}
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"Session.VariantUnavailableError",
{
providerID: Model.Ref.fields.providerID,
modelID: Model.Ref.fields.id,
variant: ModelV2.VariantID,
},
) {}
export { MessageDecodeError }
export interface CreateInput {
@@ -104,9 +86,7 @@ export interface Interface {
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
readonly switchModel: (
input: SwitchModelInput,
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
+4 -34
View File
@@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace"
@@ -25,7 +25,6 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { logFailure } from "./session/logging"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
@@ -168,20 +167,6 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const scope = yield* Effect.scope
const enqueueWake = (admitted: SessionInput.Admitted) =>
execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: logFailure("Failed to wake Session", admitted.sessionID, cause),
),
Effect.ignore,
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
@@ -342,10 +327,6 @@ export const layer = Layer.effect(
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
if (input.resume !== false) yield* enqueueWake(admitted)
return admitted
}, Effect.uninterruptible)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
@@ -363,7 +344,8 @@ export const layer = Layer.effect(
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
return yield* returnPrompt(admitted)
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
),
@@ -404,19 +386,7 @@ export const layer = Layer.effect(
yield* execution.resume(sessionID)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* execution.interrupt(sessionID)
const event = yield* events.publish(SessionEvent.InterruptRequested, {
sessionID,
timestamp: yield* DateTime.now,
})
if (event.durable === undefined)
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
yield* execution.interrupt(sessionID, event.durable.seq)
}),
),
Effect.uninterruptible(execution.interrupt(sessionID)),
),
})
+47 -216
View File
@@ -1,54 +1,31 @@
export * as SessionContextEpoch from "./context-epoch"
import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { AgentV2 } from "../agent"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { Location } from "../location"
import { SystemContext } from "../system-context/index"
import { ContextSnapshotDecodeError } from "./error"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionInput } from "./input"
import { SessionMessageID } from "./message-id"
import { SessionSchema } from "./schema"
import { SessionContextEpochTable, SessionTable } from "./sql"
import { SessionContextEpochTable } from "./sql"
type DatabaseService = Database.Interface["db"]
class RevisionMismatch extends Error {}
class LocationMismatch extends Error {}
export class AgentMismatch extends Error {}
export class AgentReplacementBlocked extends Schema.TaggedErrorClass<AgentReplacementBlocked>()(
"SessionContextEpoch.AgentReplacementBlocked",
{ sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID },
) {}
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
attempt().pipe(
Effect.catchDefect((defect) =>
defect instanceof RevisionMismatch
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
: Effect.die(defect),
),
)
interface Prepared {
readonly baseline: string
readonly baselineSeq: number
readonly revision: number
}
export function initialize(
db: DatabaseService,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
Effect.withSpan("SessionContextEpoch.initialize"),
)
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
}
export function prepare(
@@ -56,12 +33,8 @@ export function prepare(
events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
Effect.withSpan("SessionContextEpoch.prepare"),
)
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
}
const prepareOnce = Effect.fnUntraced(function* (
@@ -69,57 +42,50 @@ const prepareOnce = Effect.fnUntraced(function* (
events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
) {
const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" })
const [value, stored, compaction] = yield* Effect.all(
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
{ concurrency: "unbounded" },
)
if (!stored) {
const generation = yield* SystemContext.initialize(value)
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
return { baseline: generation.baseline, baselineSeq, revision: 0 }
const baselineSeq = yield* insert(db, sessionID, generation)
return { baseline: generation.baseline, baselineSeq }
}
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
)
const replacingAgent = stored.agent !== agent
const result =
stored.replacement_seq === null && !replacingAgent
? yield* SystemContext.reconcile(value, snapshot)
: yield* SystemContext.replace(value, snapshot)
if (result._tag === "ReplacementBlocked" && replacingAgent) {
yield* fence(db, sessionID, agent, stored.revision)
return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent })
}
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
const result = replacementSeq
? yield* SystemContext.replace(value, snapshot)
: yield* SystemContext.reconcile(value, snapshot)
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
yield* fence(db, sessionID, agent, stored.revision)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision }
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
}
if (result._tag === "ReplacementReady") {
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID))
yield* replace(db, sessionID, baselineSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq }
}
yield* events.publish(
SessionEvent.ContextUpdated,
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
{ commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 }
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
})
const initializeOnce = Effect.fnUntraced(function* (
db: DatabaseService,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
) {
if (yield* exists(db, sessionID)) return
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
return { baseline: generation.baseline, baselineSeq, revision: 0 }
const baselineSeq = yield* insert(db, sessionID, generation)
return { baseline: generation.baseline, baselineSeq }
})
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
@@ -142,39 +108,6 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
.pipe(Effect.orDie)
})
const requireAgentSelection = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
) {
const selected = yield* db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch())
})
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
seq: number,
) {
return yield* db
.update(SessionContextEpochTable)
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
lt(SessionContextEpochTable.baseline_seq, seq),
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
),
)
.run()
.pipe(Effect.orDie)
})
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@@ -189,155 +122,53 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
const insert = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
generation: SystemContext.Generation,
) {
return yield* db
.transaction(
() =>
Effect.gen(function* () {
const placed = yield* db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(
and(
eq(SessionTable.id, sessionID),
eq(SessionTable.directory, location.directory),
location.workspaceID === undefined
? isNull(SessionTable.workspace_id)
: eq(SessionTable.workspace_id, location.workspaceID),
),
)
.get()
.pipe(Effect.orDie)
if (!placed) return yield* Effect.die(new LocationMismatch())
if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch())
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
yield* db
.insert(SessionContextEpochTable)
.values({
session_id: sessionID,
baseline: generation.baseline,
agent,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
revision: 0,
})
.onConflictDoNothing()
.returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(
Effect.orDie,
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
)
return baselineSeq
}),
{ behavior: "immediate" },
)
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
yield* db
.insert(SessionContextEpochTable)
.values({
session_id: sessionID,
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
})
.run()
.pipe(Effect.orDie)
return baselineSeq
})
const replace = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
expectedRevision: number,
baselineSeq: number,
generation: SystemContext.Generation,
) {
yield* db
.transaction(
() =>
Effect.gen(function* () {
yield* requireAgentSelection(db, sessionID, agent)
const updated = yield* db
.update(SessionContextEpochTable)
.set({
baseline: generation.baseline,
agent,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
replacement_seq: null,
revision: expectedRevision + 1,
})
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new RevisionMismatch())
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const fence = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
expectedRevision: number,
) {
const current = yield* db
.select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision })
.from(SessionContextEpochTable)
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
if (!current || (current.selected !== null && current.selected !== agent))
return yield* Effect.die(new AgentMismatch())
if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch())
})
export const current = Effect.fn("SessionContextEpoch.current")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
revision: number,
) {
const value = yield* db
.select({
agent: SessionContextEpochTable.agent,
selected: SessionTable.agent,
revision: SessionContextEpochTable.revision,
const updated = yield* db
.update(SessionContextEpochTable)
.set({
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
})
.from(SessionContextEpochTable)
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
.where(eq(SessionContextEpochTable.session_id, sessionID))
.returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(Effect.orDie)
return (
value !== undefined &&
value.agent === agent &&
(value.selected === null || value.selected === agent) &&
value.revision === revision
)
if (!updated) return yield* Effect.die("Context Epoch not found")
})
const advance = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
expectedRevision: number,
snapshot: SystemContext.Snapshot,
) {
const updated = yield* db
.update(SessionContextEpochTable)
.set({ snapshot, revision: expectedRevision + 1 })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
isNull(SessionContextEpochTable.replacement_seq),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.set({ snapshot })
.where(eq(SessionContextEpochTable.session_id, sessionID))
.returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new RevisionMismatch())
if (!updated) return yield* Effect.die("Context Epoch not found")
})
+1 -20
View File
@@ -118,13 +118,6 @@ export namespace PromptLifecycle {
export type Promoted = typeof Promoted.Type
}
export const InterruptRequested = EventV2.define({
type: "session.next.interrupt.requested",
...options,
schema: Base,
})
export type InterruptRequested = typeof InterruptRequested.Type
export const ContextUpdated = EventV2.define({
type: "session.next.context.updated",
...options,
@@ -443,20 +436,9 @@ export namespace Compaction {
})
export type Delta = typeof Delta.Type
// Retain the unpublished v1 decoder so stored beta events remain replayable.
export const EndedV1 = EventV2.define({
type: "session.next.compaction.ended",
...options,
schema: {
...Base,
text: Schema.String,
include: Schema.String.pipe(Schema.optional),
},
})
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
durable: { aggregate: "sessionID", version: 2 },
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
@@ -475,7 +457,6 @@ const DurableDefinitions = [
Prompted,
PromptLifecycle.Admitted,
PromptLifecycle.Promoted,
InterruptRequested,
ContextUpdated,
Synthetic,
Shell.Started,
+4 -4
View File
@@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema"
export interface Interface {
/** Explicitly drain one Session, making at least one provider attempt. */
/** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
+9 -6
View File
@@ -1,11 +1,10 @@
import { Effect, Layer } from "effect"
import { Cause, Effect, Layer } from "effect"
import { LocationServiceMap } from "../../location-layer"
import { SessionRunCoordinator } from "../run-coordinator"
import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionExecution } from "../execution"
import { logFailure } from "../logging"
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
export const layer = Layer.effect(
@@ -13,15 +12,19 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
}),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
})
return SessionExecution.Service.of({
+2 -2
View File
@@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq))
-8
View File
@@ -1,8 +0,0 @@
import { Cause, Effect } from "effect"
import { SessionSchema } from "./schema"
export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))
@@ -138,7 +138,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void,
"session.next.interrupt.requested": () => Effect.void,
"session.next.context.updated": (event) =>
adapter.appendMessage(
new SessionMessage.System({
+5 -22
View File
@@ -329,19 +329,14 @@ export const layer = Layer.effectDiscard(
if (next) yield* applyUsage(db, sessionID, next)
}),
)
yield* events.project(SessionEvent.AgentSwitched, (event) => {
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
return db
yield* events.project(SessionEvent.AgentSwitched, (event) =>
db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(
Effect.orDie,
Effect.andThen(run(db, event)),
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)),
)
})
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.ModelSwitched, (event) =>
Effect.gen(function* () {
yield* db
@@ -351,8 +346,6 @@ export const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie)
yield* run(db, event)
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)
}),
)
yield* events.project(SessionEvent.Prompted, (event) =>
@@ -406,8 +399,6 @@ export const layer = Layer.effectDiscard(
)
}),
)
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
// TODO: Reconstruct context epoch replacement state during replay without adding replay state to every EventV2 payload.
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
@@ -426,15 +417,7 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
if (event.durable.version === 1) return Effect.void
const seq = event.durable.seq
return Effect.gen(function* () {
yield* run(db, event)
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
})
})
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
}),
)
+54 -236
View File
@@ -1,106 +1,43 @@
export * as SessionRunCoordinator from "./run-coordinator"
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
import { SessionRunner } from "./runner"
import { logFailure } from "./logging"
import { SessionSchema } from "./schema"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
export type Mode = "run" | "wake"
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
/**
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
*
* For each key:
*
* idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle
*
* `run` is an explicit drain request. It starts a chain or joins the current chain and
* upgrades a pending follow-up so the caller receives explicit-run semantics.
*
* `wake` reports that durable work may now be available. It starts a chain while idle or
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
*
* `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt
* boundary are suppressed; advisory wakes after the boundary run after cleanup.
*/
export interface Coordinator<Key, A, E> {
/** Starts or joins one explicit drain generation. */
readonly run: (key: Key) => Effect.Effect<A, E>
/** Coalesces one wake-up after durable work is recorded. */
readonly wake: (key: Key, seq?: number) => Effect.Effect<void>
/** Waits until the current ownership chain settles. */
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
/** Interrupts the active ownership chain without automatically draining pending wakes. */
readonly interrupt: (key: Key, seq?: number) => Effect.Effect<void>
/** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E> {
/** Starts execution while idle or joins the active execution. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Registers one coalesced follow-up after newly recorded work. */
readonly wake: (key: Key) => Effect.Effect<void>
/** Stops active execution and waits for its cleanup. */
readonly interrupt: (key: Key) => Effect.Effect<void>
}
/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
type Entry<A, E> = {
readonly done: Deferred.Deferred<A, E>
readonly settled: Deferred.Deferred<Exit.Exit<A, E>>
current: Demand
pending?: Demand
explicitWaiter?: Deferred.Deferred<A, E>
interruptSeq?: number
type Entry<E> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void, never>
pendingWake: boolean
stopping: boolean
}
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
const coalesce = (left: Demand | undefined, right: Demand): Demand => {
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
}
const maxSeq = (left: number | undefined, right: number | undefined) => {
if (left === undefined) return right
if (right === undefined) return left
return Math.max(left, right)
}
/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */
export const make = <Key, A, E>(options: {
readonly drain: (key: Key, mode: Mode) => Effect.Effect<A, E>
readonly onFailure?: (key: Key, cause: Cause.Cause<E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
export const make = <Key, E>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const active = new Map<Key, Entry<A, E>>()
const interruptSeq = new Map<Key, number>()
const report = yield* FiberSet.makeRuntime<never, void, never>()
const active = new Map<Key, Entry<E>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const shutdown = Deferred.makeUnsafe<void>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
Deferred.doneUnsafe(shutdown, Effect.void)
active.clear()
interruptSeq.clear()
}),
)
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({
done: Deferred.makeUnsafe<A, E>(),
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(),
current,
explicitWaiter,
const makeEntry = (): Entry<E> => ({
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
stopping: false,
})
const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
const ready = Deferred.makeUnsafe<void>()
const drain = Effect.suspend(() => options.drain(key, demand._tag))
// Initial work retains immediate-start behavior but cannot run before ownership is published.
// Observer-started successors yield once so synchronous drains cannot recurse on the JS stack.
const owner = fork(
(successor
? Effect.yieldNow.pipe(Effect.andThen(drain))
: Deferred.await(ready).pipe(Effect.andThen(drain))
).pipe(
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
Effect.andThen(Effect.suspend(() => options.drain(key, force))),
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
Effect.exit,
Effect.asVoid,
),
@@ -109,176 +46,57 @@ export const make = <Key, A, E>(options: {
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
}
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
if (closed) {
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
if (demand._tag === "run" && entry.explicitWaiter !== undefined) {
Deferred.doneUnsafe(entry.explicitWaiter, exit)
entry.explicitWaiter = undefined
}
if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) {
Deferred.doneUnsafe(entry.explicitWaiter, exit)
entry.explicitWaiter = undefined
}
if (active.get(key) !== entry) {
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
if (exit._tag === "Success" && !entry.stopping) {
if (entry.pending !== undefined) {
const pending = entry.pending
entry.pending = undefined
entry.current = pending
start(key, entry, pending, true)
return
}
active.delete(key)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
entry.pendingWake = false
start(key, entry, false, true)
return
}
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
const successor = entry.pendingWake ? makeEntry() : undefined
if (successor === undefined) active.delete(key)
else active.set(key, successor)
if (successor !== undefined) start(key, successor, successor.current, true)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
if (
exit._tag === "Failure" &&
!(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) &&
demand._tag === "wake" &&
options.onFailure !== undefined
) {
report(Effect.suspend(() => options.onFailure!(key, exit.cause)))
else {
active.set(key, successor)
start(key, successor, false, true)
}
Deferred.doneUnsafe(entry.done, exit)
}
const wake = (key: Key, seq?: number) =>
Effect.sync(() => {
if (closed) return
if (!isAfterInterrupt(key, seq)) return
const run = (key: Key): Effect.Effect<void, E> =>
Effect.uninterruptibleMask((restore) => {
const entry = active.get(key)
if (entry !== undefined) {
if (!acceptsWake(entry, seq)) return
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
return restore(Deferred.await(entry.done))
}
const next = makeEntry()
active.set(key, next)
start(key, next, true)
return restore(Deferred.await(next.done))
})
const wake = (key: Key) =>
Effect.sync(() => {
const entry = active.get(key)
if (entry !== undefined) {
entry.pendingWake = true
return
}
const next = makeEntry({ _tag: "wake", seq })
const next = makeEntry()
active.set(key, next)
start(key, next, next.current)
start(key, next, false)
})
const awaitIdle = (key: Key): Effect.Effect<void, E> =>
Effect.gen(function* () {
let firstFailure: Cause.Cause<E> | undefined
while (!closed) {
const entry = active.get(key)
if (entry === undefined) break
const exit = yield* Effect.raceFirst(
Deferred.await(entry.settled),
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
)
if (closed) break
if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause
}
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
})
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
const interrupt = (key: Key): Effect.Effect<void> =>
Effect.suspend(() => {
const entry = active.get(key)
const latest = interruptSeq.get(key)
if (seq !== undefined && latest !== undefined && seq <= latest)
return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void
if (seq !== undefined) interruptSeq.set(key, seq)
if (entry?.owner === undefined) return Effect.void
if (
seq !== undefined &&
entry.current._tag === "wake" &&
entry.current.seq !== undefined &&
entry.current.seq > seq
)
return Effect.void
if (entry.stopping) {
entry.interruptSeq = maxSeq(entry.interruptSeq, seq)
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner)
}
entry.stopping = true
entry.interruptSeq = seq
suppressPendingAtOrBefore(entry, seq)
entry.pendingWake = false
return Fiber.interrupt(entry.owner)
})
return { run, wake, awaitIdle, interrupt }
function run(key: Key): Effect.Effect<A, E> {
return Effect.uninterruptibleMask((restore) => {
if (closed) return Effect.interrupt
const entry = active.get(key)
if (entry !== undefined) {
if (entry.stopping) {
return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key))))
}
if (entry.current._tag === "wake") {
entry.pending = coalesce(entry.pending, { _tag: "run" })
entry.explicitWaiter ??= Deferred.makeUnsafe<A, E>()
return restore(awaitRun(entry.explicitWaiter))
}
return restore(awaitRun(entry.done))
}
const next = makeEntry({ _tag: "run" })
active.set(key, next)
start(key, next, next.current)
return restore(awaitRun(next.done))
})
}
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
}
function acceptsWake(entry: Entry<A, E>, seq: number | undefined) {
return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq)
}
function isAfterInterrupt(key: Key, seq: number | undefined) {
const latest = interruptSeq.get(key)
return latest === undefined || (seq !== undefined && seq > latest)
}
function suppressPendingAtOrBefore(entry: Entry<A, E>, seq: number | undefined) {
if (
entry.pending?._tag === "wake" &&
seq !== undefined &&
entry.pending.seq !== undefined &&
entry.pending.seq > seq
)
return
entry.pending = undefined
}
return { run, wake, interrupt }
})
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunCoordinator") {}
export const layer = Layer.effect(
Service,
SessionRunner.Service.pipe(
Effect.flatMap((runner) =>
make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
}),
),
Effect.map(Service.of),
),
)
+1 -3
View File
@@ -6,7 +6,6 @@ import { SessionSchema } from "../schema"
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store"
export type RunError =
@@ -15,7 +14,6 @@ export type RunError =
| MessageDecodeError
| ContextSnapshotDecodeError
| SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
@@ -23,7 +21,7 @@ export interface Interface {
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
readonly run: (input: {
readonly sessionID: SessionSchema.ID
readonly force?: boolean
readonly force: boolean
}) => Effect.Effect<void, RunError>
}
+12 -40
View File
@@ -8,7 +8,7 @@ import {
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
@@ -141,8 +141,8 @@ export const layer = Layer.effect(
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
type TurnTransition =
// Request preparation observed a concurrent Session change and must restart from durable state.
| { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
// Automatic compaction completed; rebuild the request from compacted history.
| { readonly _tag: "ContinueAfterCompaction" }
// Overflow compaction completed; rebuild once through the path without overflow recovery.
| { readonly _tag: "ContinueAfterOverflowCompaction" }
@@ -152,20 +152,11 @@ export const layer = Layer.effect(
}
}
const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" })
const continueAfterOverflowCompaction = new TurnTransitionError({
_tag: "ContinueAfterOverflowCompaction",
})
const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) =>
Effect.catchDefect((defect) =>
defect instanceof SessionContextEpoch.AgentMismatch
? Effect.die(rebuildPreparedTurn(promotion))
: Effect.die(defect),
)
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
concurrency: "unbounded",
@@ -181,13 +172,7 @@ export const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
const agent = yield* agents.select(session.agent)
const initialized = yield* SessionContextEpoch.initialize(
db,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(promotion))
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
if (promotion) {
@@ -199,18 +184,7 @@ export const layer = Layer.effect(
}
}
const system =
initialized ??
(yield* SessionContextEpoch.prepare(
db,
events,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(undefined)))
const current = yield* getSession(sessionID)
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
return yield* Effect.die(rebuildPreparedTurn())
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
@@ -228,7 +202,7 @@ export const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
return yield* Effect.die(continueAfterCompaction)
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
@@ -242,8 +216,6 @@ export const layer = Layer.effect(
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths))
let overflowFailure: ProviderErrorEvent | undefined
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
return yield* Effect.die(rebuildPreparedTurn())
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
@@ -352,7 +324,7 @@ export const layer = Layer.effect(
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
}),
),
)
@@ -366,7 +338,7 @@ export const layer = Layer.effect(
yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
return yield* runTurn(sessionID, defect.transition.promotion, step)
return yield* runTurn(sessionID, undefined, step)
}),
),
)
@@ -374,14 +346,14 @@ export const layer = Layer.effect(
const run = Effect.fn("SessionRunner.run")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force?: boolean
readonly force: boolean
}) {
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (input.force !== true && !hasSteer && !hasQueue) return
if (!input.force && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID)
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force === true || hasSteer || hasQueue
let openActivity = input.force || hasSteer || hasQueue
while (openActivity) {
let needsContinuation = true
for (let step = 1; needsContinuation; step++) {
+58 -12
View File
@@ -5,7 +5,7 @@ import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-message
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
@@ -24,6 +24,23 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
},
) {}
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
},
) {}
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {}
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
"SessionRunnerModel.UnsupportedApiError",
{
@@ -33,7 +50,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
},
) {}
export type Error = ModelNotSelectedError | UnsupportedApiError
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
@@ -70,13 +87,27 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
})
}
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
const variant = model.variants.find((item) => item.id === id)
if (!variant) return model
return produce(model, (draft) => {
ModelRequest.assign(draft.request, variant)
})
if (!variant && variantID !== undefined && variantID !== "default")
return Effect.fail(
new VariantUnavailableError({
providerID: model.providerID,
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
ModelRequest.assign(draft.request, variant)
})
: model,
)
}
const apiName = (model: ModelV2.Info) =>
@@ -124,8 +155,15 @@ export const fromCatalogModel = (
)
}
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant))
export const resolve = (
session: SessionSchema.Info,
model: ModelV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Info,
) =>
withVariant(model, session.model?.variant).pipe(
Effect.flatMap((model) => fromCatalogModel(model, connection, credential)),
)
export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" &&
@@ -147,14 +185,22 @@ export const locationLayer = Layer.effect(
yield* boot.wait()
const defaultModel = session.model ? undefined : yield* catalog.model.default()
const selected = session.model
? yield* catalog.model.get(session.model.providerID, session.model.id)
? (yield* catalog.model.available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
: defaultModel && supported(defaultModel)
? defaultModel
: (yield* catalog.model.available()).find(supported)
if (!selected && session.model)
return yield* new ModelUnavailableError({
providerID: session.model.providerID,
modelID: session.model.id,
})
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
return yield* fromCatalogModel(
withVariant(selected, session.model?.variant),
return yield* resolve(
session,
selected,
connection,
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
)
-3
View File
@@ -170,9 +170,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
.primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text().notNull(),
agent: text().$type<AgentV2.ID>().notNull().default(AgentV2.defaultID),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
baseline_seq: integer().notNull(),
replacement_seq: integer(),
revision: integer().notNull().default(0),
})
+94 -2
View File
@@ -14,6 +14,8 @@ import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/m
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
import resetV2SessionStateMigration from "@opencode-ai/core/database/migration/20260622170816_reset_v2_session_state"
import { EventV2 } from "@opencode-ai/core/event"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -22,6 +24,8 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { Database } from "@opencode-ai/core/database/database"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { tmpdir } from "./fixture/tmpdir"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
@@ -71,9 +75,9 @@ describe("DatabaseMigration", () => {
).toEqual({ name: "session_context_epoch" })
expect(
yield* db.get(
sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`,
sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
),
).toEqual({ name: "agent", dflt_value: "'build'" })
).toBeUndefined()
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(
@@ -226,6 +230,94 @@ describe("DatabaseMigration", () => {
)
})
test("preserves canonical V1 state and restarts its event stream", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* DatabaseMigration.apply(db)
yield* db.run(
sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/project', 1, 1, '[]')`,
)
yield* db.run(
sql`INSERT INTO workspace (id, type, project_id, time_used) VALUES ('workspace', 'local', 'global', 1)`,
)
yield* db.run(
sql`INSERT INTO session (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'global', 'workspace', 'session', '/project', 'Before', 'test', 1, 1)`,
)
yield* db.run(
sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('message', 'session', 1, 1, '{}')`,
)
yield* db.run(
sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('part', 'message', 'session', 1, 1, '{}')`,
)
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`)
yield* db.run(
sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`,
)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
)
yield* db.run(
sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
)
yield* db.run(sql`DELETE FROM migration WHERE id = ${resetV2SessionStateMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [resetV2SessionStateMigration])
const database = Layer.succeed(Database.Service, { db })
const events = EventV2.layer.pipe(Layer.provide(database))
yield* EventV2.Service.use((service) =>
service.publish(SessionV1.Event.Updated, {
sessionID: SessionSchema.ID.make("session"),
info: {
id: SessionSchema.ID.make("session"),
slug: "session",
projectID: ProjectV2.ID.global,
directory: "/project",
title: "After",
version: "test",
time: { created: 1, updated: 2 },
},
}),
).pipe(
Effect.provide(
Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))),
),
)
expect(
yield* db.get(sql`
SELECT
(SELECT title FROM session WHERE id = 'session') AS title,
(SELECT workspace_id FROM session WHERE id = 'session') AS workspaceID,
(SELECT COUNT(*) FROM message WHERE id = 'message') AS messages,
(SELECT COUNT(*) FROM part WHERE id = 'part') AS parts,
(SELECT COUNT(*) FROM workspace) AS workspaces,
(SELECT COUNT(*) FROM session_input) AS sessionInputs,
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
(SELECT COUNT(*) FROM session_context_epoch) AS contextEpochs,
(SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
(SELECT type FROM event WHERE aggregate_id = 'session') AS eventType
`),
).toEqual({
title: "After",
workspaceID: null,
messages: 1,
parts: 1,
workspaces: 0,
sessionInputs: 0,
sessionMessages: 0,
contextEpochs: 0,
seq: 0,
eventType: "session.updated.1",
})
}),
)
})
test("resets incompatible projected Session messages before adding sequence order", async () => {
await run(
Effect.gen(function* () {
+55 -1
View File
@@ -1,16 +1,20 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
import { Tool } from "@opencode-ai/core/public"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool"
@@ -136,6 +140,56 @@ describe("LocationServiceMap", () => {
),
)
it.live("rejects an unavailable selected model during location model resolution", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
yield* Effect.promise(() =>
fs.writeFile(
path.join(dir.path, "opencode.json"),
JSON.stringify({
providers: {
unavailable: {
name: "Unavailable",
api: { type: "native", settings: {} },
models: { chat: { disabled: true } },
},
},
}),
),
)
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
SessionV2.Info.make({
id: SessionV2.ID.make("ses_unavailable_model"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: ModelV2.ID.make("chat"),
providerID: ProviderV2.ID.make("unavailable"),
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
),
).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
providerID: "unavailable",
modelID: "chat",
})
}),
),
),
)
it.live("installs public plugins into a location", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+16 -117
View File
@@ -1,9 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(OpenCode.layer)
@@ -41,94 +38,24 @@ describe("public native OpenCode API", () => {
}),
)
it.effect("switches to an available model and variant", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* writeProvider(tmp.path)
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_available")
const model = ref({ variant: "fast" })
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
})
it.effect("records model selection without resolving the Location catalog", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_deferred")
const model = Schema.decodeUnknownSync(Model.Ref)({
id: "missing",
providerID: "missing",
variant: "unknown",
})
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }),
})
yield* opencode.sessions.switchModel({ sessionID, model })
yield* opencode.sessions.switchModel({ sessionID, model })
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
}),
),
),
)
it.effect("rejects missing and Location-disabled models without changing the Session", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([available, disabled]) =>
Effect.gen(function* () {
yield* writeProvider(available.path)
yield* writeProvider(disabled.path, true)
const opencode = yield* OpenCode.Service
const availableID = Session.ID.make("ses_public_switch_exact_available")
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
yield* opencode.sessions.create({
id: availableID,
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
})
yield* opencode.sessions.create({
id: disabledID,
location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
})
yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
const disabledError = yield* opencode.sessions
.switchModel({ sessionID: disabledID, model: ref() })
.pipe(Effect.flip)
const missingError = yield* opencode.sessions
.switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
.pipe(Effect.flip)
expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
}),
),
),
)
it.effect("rejects an unavailable variant without changing the Session", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* writeProvider(tmp.path)
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_variant")
const selected = ref({ variant: "fast" })
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
})
yield* opencode.sessions.switchModel({ sessionID, model: selected })
const error = yield* opencode.sessions
.switchModel({ sessionID, model: ref({ variant: "unknown" }) })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Session.VariantUnavailableError)
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected)
}),
),
),
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
}),
)
it.effect("preserves the typed not-found error for a missing Session", () =>
@@ -147,31 +74,3 @@ describe("public native OpenCode API", () => {
}),
)
})
const ref = (input: { id?: string; variant?: string } = {}) =>
Schema.decodeUnknownSync(Model.Ref)({
id: input.id ?? "chat",
providerID: "public-test",
variant: input.variant,
})
const writeProvider = (directory: string, disabled = false) =>
Effect.promise(() =>
fs.writeFile(
path.join(directory, "opencode.json"),
JSON.stringify({
providers: {
"public-test": {
name: "Public test",
api: { type: "native", settings: {} },
models: {
chat: {
disabled,
variants: [{ id: "fast" }],
},
},
},
},
}),
),
)
@@ -1,30 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Logger } from "effect"
import { logFailure } from "@opencode-ai/core/session/logging"
import { SessionSchema } from "@opencode-ai/core/session/schema"
describe("Session logging", () => {
for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) {
test(`renders the cause for ${message}`, async () => {
const entries: Array<ReturnType<typeof Logger.formatStructured.log>> = []
const logger = Logger.formatStructured.pipe(
Logger.map((entry): void => {
entries.push(entry)
}),
)
await logFailure(
message,
SessionSchema.ID.make("session-123"),
Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }),
).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise)
expect(entries).toHaveLength(1)
expect(entries[0]?.message).toBe(message)
expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" })
expect(entries[0]?.cause).toContain("SessionFailure")
expect(entries[0]?.cause).toContain("nested-code")
expect(entries[0]?.cause).not.toContain("[Object")
})
}
})
+5 -31
View File
@@ -20,9 +20,7 @@ import { testEffect } from "./lib/effect"
const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
const interruptSeqs: Array<number | undefined> = []
const wakeCalls: SessionV2.ID[] = []
const wakeSeqs: Array<number | undefined> = []
const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
@@ -30,15 +28,13 @@ const execution = Layer.succeed(
Effect.sync(() => {
executionCalls.push(sessionID)
}),
interrupt: (sessionID, seq) =>
interrupt: (sessionID) =>
Effect.sync(() => {
interruptCalls.push(sessionID)
interruptSeqs.push(seq)
}),
wake: (sessionID, seq) =>
wake: (sessionID) =>
Effect.sync(() => {
wakeCalls.push(sessionID)
wakeSeqs.push(seq)
}),
}),
)
@@ -109,15 +105,6 @@ const eventCount = (type: string) =>
),
)
const interruptEvent = Database.Service.use(({ db }) =>
db
.select()
.from(EventTable)
.where(eq(EventTable.type, "session.next.interrupt.requested.1"))
.get()
.pipe(Effect.orDie),
)
describe("SessionV2.prompt", () => {
it.effect("delegates execution continuation through SessionExecution", () =>
Effect.gen(function* () {
@@ -131,19 +118,14 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("delegates interruption through SessionExecution", () =>
it.effect("delegates process-local interruption through SessionExecution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
interruptCalls.length = 0
interruptSeqs.length = 0
yield* session.interrupt(sessionID)
expect(interruptCalls).toEqual([sessionID])
expect(interruptSeqs).toHaveLength(1)
expect(typeof interruptSeqs[0]).toBe("number")
expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(1)
expect(yield* interruptEvent).toMatchObject({ aggregate_id: sessionID, seq: interruptSeqs[0] })
expect(yield* session.messages({ sessionID })).toEqual([])
}),
)
@@ -152,11 +134,9 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () {
const session = yield* SessionV2.Service
interruptCalls.length = 0
interruptSeqs.length = 0
yield* session.interrupt(SessionV2.ID.make("ses_missing"))
expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")])
expect(interruptSeqs).toEqual([undefined])
}),
)
@@ -515,13 +495,11 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
const admitted = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
expect(wakeSeqs).toEqual([admitted.admittedSeq])
}),
)
@@ -531,9 +509,8 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
const admitted = yield* session.prompt({
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Run explicitly" }),
resume: true,
@@ -541,7 +518,6 @@ describe("SessionV2.prompt", () => {
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
expect(wakeSeqs).toEqual([admitted.admittedSeq])
}),
)
@@ -551,13 +527,11 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([])
expect(wakeSeqs).toEqual([])
}),
)
})
+166 -832
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { testEffect } from "./lib/effect"
@@ -22,14 +22,39 @@ describe("SessionRunCoordinator", () => {
expect(runs).toBe(1)
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(runs).toBe(1)
}),
),
)
it.effect("starts a drain when woken while idle", () =>
it.effect("joins a wake-started execution without forcing a successor", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const gate = yield* Deferred.make<void>()
const forces: boolean[] = []
const coordinator = yield* SessionRunCoordinator.make<string, never>({
drain: (_key, force) =>
Effect.sync(() => forces.push(force)).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Deferred.await(gate)),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(resumed)
expect(forces).toEqual([false])
}),
),
)
it.effect("starts execution when woken while idle", () =>
Effect.scoped(
Effect.gen(function* () {
const drained = yield* Deferred.make<void>()
@@ -41,62 +66,11 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("does nothing when interrupted while idle", () =>
Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
yield* coordinator.interrupt("session")
}),
),
)
it.effect("suppresses stale wakes after an idle interrupt boundary", () =>
Effect.scoped(
Effect.gen(function* () {
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => runs++) })
yield* coordinator.interrupt("session", 2)
yield* coordinator.wake("session", 1)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(0)
yield* coordinator.wake("session", 3)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
}),
),
)
it.effect("does not interrupt a wake newer than the interrupt boundary", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const gate = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(gate)),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
})
yield* coordinator.wake("session", 3)
yield* Deferred.await(started)
yield* coordinator.interrupt("session", 2)
expect(yield* Deferred.isDone(interrupted)).toBeFalse()
yield* Deferred.succeed(gate, undefined)
yield* coordinator.awaitIdle("session")
}),
),
)
it.effect("preserves a queued wake newer than the interrupt boundary", () =>
it.effect("coalesces wakes received during active execution", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
@@ -104,569 +78,33 @@ describe("SessionRunCoordinator", () => {
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(firstGate)))
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session", 1)
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", 3)
yield* coordinator.interrupt("session", 2)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts only the requested key", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const secondInterrupted = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) =>
key === "first"
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
: Deferred.succeed(secondStarted, undefined).pipe(
Effect.andThen(Deferred.await(secondGate)),
Effect.onInterrupt(() => Deferred.succeed(secondInterrupted, undefined)),
),
})
yield* coordinator.wake("first")
yield* coordinator.wake("second")
yield* Effect.all([Deferred.await(firstStarted), Deferred.await(secondStarted)])
yield* coordinator.interrupt("first")
expect(yield* Deferred.isDone(secondInterrupted)).toBeFalse()
yield* Deferred.succeed(secondGate, undefined)
yield* coordinator.awaitIdle("second")
}),
),
)
it.effect("interrupts the active drain and suppresses its queued wake", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
)
: Effect.void,
),
),
})
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session")
yield* coordinator.interrupt("session")
yield* Deferred.await(interrupted)
yield* coordinator.awaitIdle("session")
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(runs).toBe(1)
yield* coordinator.interrupt("session")
}),
),
)
it.effect("suppresses a wake received during interruption cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", 1)
yield* Deferred.await(firstInterrupted)
expect(runs).toBe(1)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
yield* coordinator.wake("session", 3)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("remembers a wake received after the interrupt boundary during cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Deferred.await(firstInterrupted)
yield* coordinator.wake("session", 3)
const staleInterrupt = yield* coordinator.interrupt("session", 1).pipe(Effect.forkChild)
expect(runs).toBe(1)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Fiber.join(staleInterrupt)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("moves the stop barrier forward for repeated interrupts", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const firstInterrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Deferred.await(firstInterrupted)
yield* coordinator.wake("session", 3)
const secondInterrupt = yield* coordinator.interrupt("session", 4).pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(firstInterrupt)
yield* Fiber.join(secondInterrupt)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
yield* coordinator.wake("session", 5)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts an explicit run queued before the interruption request", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1 ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void,
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.interrupt("session")
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(runs).toBe(1)
}),
),
)
it.effect("settles a pre-interrupt explicit run only after active wake cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const runSettled = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
const run = yield* coordinator
.run("session")
.pipe(Effect.exit, Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
expect(yield* Deferred.isDone(runSettled)).toBeFalse()
yield* Deferred.succeed(cleanupGate, undefined)
const runExit = yield* Fiber.join(run)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
}),
),
)
it.effect("starts an explicit run arriving during interrupt cleanup after the stop barrier", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Fiber.join(run)
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts pre-stop waiters and runs post-stop waiters after cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const before = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const after = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
const beforeExit = yield* Fiber.join(before)
expect(Exit.isFailure(beforeExit) && Cause.hasInterruptsOnly(beforeExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
yield* Fiber.join(after)
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("waits for interrupt cleanup before settling callers", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const runSettled = yield* Deferred.make<void>()
const idleSettled = yield* Deferred.make<void>()
const interruptSettled = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
const run = yield* coordinator
.run("session")
.pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
yield* Deferred.await(started)
const idle = yield* coordinator
.awaitIdle("session")
.pipe(Effect.exit, Effect.ensuring(Deferred.succeed(idleSettled, undefined)), Effect.forkChild)
const interrupt = yield* coordinator
.interrupt("session")
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
yield* Deferred.await(cleanupStarted)
expect(yield* Deferred.isDone(runSettled)).toBeFalse()
expect(yield* Deferred.isDone(idleSettled)).toBeFalse()
expect(yield* Deferred.isDone(interruptSettled)).toBeFalse()
yield* Deferred.succeed(cleanupGate, undefined)
const runExit = yield* Fiber.await(run)
const idleExit = yield* Fiber.join(idle)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(Exit.isFailure(idleExit) && Cause.hasInterruptsOnly(idleExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
}),
),
)
it.effect("joins concurrent interruption requests for one active drain", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
const first = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const second = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
it.effect("does not discard a post-stop explicit run when interrupted again", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const firstInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
const secondInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(firstInterrupt), Fiber.join(secondInterrupt), Fiber.join(run)])
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("coalesces wakes received during an active run", () =>
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(Effect.flatMap((run) => (run === 1 ? Deferred.await(gate) : Effect.void))),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Effect.all([coordinator.wake("session"), coordinator.wake("session"), coordinator.wake("session")], {
concurrency: "unbounded",
})
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(first)
expect(runs).toBe(2)
}),
),
)
it.effect("waits for a coalesced ownership chain to become idle", () =>
Effect.scoped(
Effect.gen(function* () {
const firstGate = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const idleSettled = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.await(firstGate)
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))),
),
),
})
yield* coordinator.wake("session")
const idle = yield* coordinator
.awaitIdle("session")
.pipe(Effect.andThen(Deferred.succeed(idleSettled, undefined)), Effect.forkChild)
yield* coordinator.wake("session")
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
expect(yield* Deferred.isDone(idleSettled)).toBeFalse()
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(idle)
yield* Fiber.join(resumed)
expect(runs).toBe(2)
}),
),
)
it.effect("reports the first defect after a failed chain becomes idle", () =>
Effect.scoped(
Effect.gen(function* () {
const firstGate = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const defect = new Error("defect")
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.await(firstGate).pipe(Effect.andThen(Effect.die(defect)))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))),
),
),
})
yield* coordinator.wake("session")
const idle = yield* coordinator
.awaitIdle("session")
.pipe(Effect.catchDefect(Effect.succeed), Effect.forkChild({ startImmediately: true }))
yield* coordinator.wake("session")
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
yield* Deferred.succeed(secondGate, undefined)
expect(yield* Fiber.join(idle)).toBe(defect)
expect(runs).toBe(2)
}),
),
)
it.effect("runs again when woken during the coalesced drain", () =>
it.effect("runs again when woken during the follow-up", () =>
Effect.scoped(
Effect.gen(function* () {
const firstGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const thirdStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
@@ -676,196 +114,168 @@ describe("SessionRunCoordinator", () => {
? Deferred.await(firstGate)
: run === 2
? Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate)))
: Effect.void,
: Deferred.succeed(thirdStarted, undefined),
),
),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
yield* coordinator.wake("session")
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(first)
yield* Deferred.await(thirdStarted)
yield* Fiber.join(resumed)
expect(runs).toBe(3)
}),
),
)
it.effect("starts one successor after a wake races with failure", () =>
it.effect("does nothing when interrupted while idle", () =>
Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
yield* coordinator.interrupt("session")
}),
),
)
it.effect("interrupts active execution and clears its pending wake", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
})
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* coordinator.wake("session")
yield* coordinator.interrupt("session")
yield* Deferred.await(interrupted)
const exit = yield* Fiber.await(resumed)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(runs).toBe(1)
}),
),
)
it.effect("runs a wake registered during interruption cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
yield* coordinator.wake("session")
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("starts a resume registered during interruption cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const forces: boolean[] = []
const coordinator = yield* SessionRunCoordinator.make<string, never>({
drain: (_key, force) => {
forces.push(force)
return forces.length === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined)
},
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(interrupt), Fiber.join(resumed)])
yield* Deferred.await(secondStarted)
expect(forces).toEqual([false, true])
}),
),
)
it.effect("starts one follow-up when a wake races with failure", () =>
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const failure = new Error("failed")
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1 ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) : Effect.void,
run === 1
? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure)))
: Deferred.succeed(secondStarted, undefined),
),
),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
yield* Deferred.succeed(gate, undefined)
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure)
yield* Effect.yieldNow
expect(yield* Fiber.join(resumed).pipe(Effect.flip)).toBe(failure)
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("upgrades an active wake when an explicit run joins it", () =>
Effect.scoped(
Effect.gen(function* () {
const wakeStarted = yield* Deferred.make<void>()
const wakeGate = yield* Deferred.make<void>()
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (_key, mode) =>
Effect.sync(() => modes.push(mode)).pipe(
Effect.andThen(
mode === "wake"
? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
: Effect.void,
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(wakeStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(wakeGate, undefined)
yield* Fiber.join(run)
expect(modes).toEqual(["wake", "run"])
}),
),
)
it.effect("upgrades a recursive wake drain when an explicit run joins it", () =>
Effect.scoped(
Effect.gen(function* () {
const runGate = yield* Deferred.make<void>()
const wakeStarted = yield* Deferred.make<void>()
const wakeGate = yield* Deferred.make<void>()
const forcedStarted = yield* Deferred.make<void>()
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (_key, mode) =>
Effect.gen(function* () {
modes.push(mode)
if (modes.length === 1) return yield* Deferred.await(runGate)
if (modes.length === 2)
return yield* Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
yield* Deferred.succeed(forcedStarted, undefined)
}),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
yield* Deferred.succeed(runGate, undefined)
yield* Deferred.await(wakeStarted)
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(wakeGate, undefined)
yield* Deferred.await(forcedStarted)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(modes).toEqual(["run", "wake", "run"])
}),
),
)
it.effect("propagates an upgraded explicit run failure before a successful advisory successor", () =>
Effect.scoped(
Effect.gen(function* () {
const wakeStarted = yield* Deferred.make<void>()
const wakeGate = yield* Deferred.make<void>()
const runStarted = yield* Deferred.make<void>()
const runGate = yield* Deferred.make<void>()
const advisoryStarted = yield* Deferred.make<void>()
const failure = new Error("explicit run failed")
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
drain: (_key, mode) =>
Effect.sync(() => modes.push(mode)).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
: run === 2
? Deferred.succeed(runStarted, undefined).pipe(
Effect.andThen(Deferred.await(runGate)),
Effect.andThen(Effect.fail(failure)),
)
: Deferred.succeed(advisoryStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(wakeStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(wakeGate, undefined)
yield* Deferred.await(runStarted)
yield* coordinator.wake("session")
yield* Deferred.succeed(runGate, undefined)
yield* Deferred.await(advisoryStarted)
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(modes).toEqual(["wake", "run", "wake"])
}),
),
)
it.effect("settles active callers when its owning scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const started = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}).pipe(Scope.provide(scope))
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(started)
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Scope.close(scope, Exit.void)
const runExit = yield* Fiber.await(run)
const idleExit = yield* Fiber.await(idle)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(Exit.isSuccess(idleExit)).toBeTrue()
}),
)
it.effect("does not start work after its owning scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Effect.sync(() => runs++),
}).pipe(Scope.provide(scope))
yield* Scope.close(scope, Exit.void)
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session")
const runExit = yield* coordinator.run("session").pipe(Effect.exit)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(runs).toBe(0)
}),
)
it.effect("does not cancel the owner when one joined waiter is interrupted", () =>
it.effect("does not cancel execution when a joined waiter is interrupted", () =>
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
@@ -904,105 +314,29 @@ describe("SessionRunCoordinator", () => {
const second = yield* coordinator.run("second").pipe(Effect.forkChild)
yield* Deferred.await(bothStarted)
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
}),
),
)
it.effect("reports an advisory drain failure exactly once", () =>
Effect.scoped(
Effect.gen(function* () {
const failure = new Error("wake failed")
const reported: Cause.Cause<Error>[] = []
const reportedOnce = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
drain: () => Effect.fail(failure),
onFailure: (_key, cause) =>
Effect.sync(() => reported.push(cause)).pipe(Effect.andThen(Deferred.succeed(reportedOnce, undefined))),
})
yield* coordinator.wake("session")
yield* Deferred.await(reportedOnce)
yield* Effect.yieldNow
expect(reported).toHaveLength(1)
expect(Cause.squash(reported[0]!)).toBe(failure)
}),
),
)
it.effect("contains defects thrown while constructing an advisory failure report", () =>
Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
drain: () => Effect.fail(new Error("wake failed")),
onFailure: () => {
throw new Error("report defect")
},
})
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
}),
),
)
it.effect("reports an independently interrupted advisory drain", () =>
Effect.scoped(
Effect.gen(function* () {
const reported = yield* Deferred.make<Cause.Cause<never>>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () => Effect.interrupt,
onFailure: (_key, cause) => Deferred.succeed(reported, cause).pipe(Effect.asVoid),
})
yield* coordinator.wake("session")
expect(Cause.hasInterruptsOnly(yield* Deferred.await(reported))).toBeTrue()
}),
),
)
it.effect("does not report deliberate interruption as an advisory failure", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const reported: Cause.Cause<never>[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
onFailure: (_key, cause) => Effect.sync(() => reported.push(cause)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session")
yield* Effect.yieldNow
expect(reported).toEqual([])
}),
),
)
it.effect("trampolines many synchronous self-waking drains", () =>
it.effect("trampolines synchronous self-waking execution", () =>
Effect.scoped(
Effect.gen(function* () {
const limit = 20_000
const completed = yield* Deferred.make<void>()
let runs = 0
let wake: (key: string) => Effect.Effect<void> = () => Effect.void
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
const coordinator = yield* SessionRunCoordinator.make<string, never>({
drain: (key) =>
Effect.sync(() => ++runs).pipe(
Effect.tap((run) => (run < limit ? wake(key) : Effect.void)),
Effect.tap((run) => (run < limit ? wake(key) : Deferred.succeed(completed, undefined))),
Effect.asVoid,
),
})
wake = coordinator.wake
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session")
yield* Deferred.await(completed)
expect(runs).toBe(limit)
}),
@@ -43,14 +43,6 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
limit: { context: 100, output: 20 },
})
const provider = (api: ProviderV2.Info["api"]) =>
new ProviderV2.Info({
id: ProviderV2.ID.make("test-provider"),
name: "Test provider",
api,
request: { headers: {}, body: {} },
})
describe("SessionRunnerModel", () => {
it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () =>
Effect.gen(function* () {
@@ -194,6 +186,35 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" })
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant_unavailable"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("unknown"),
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.VariantUnavailableError",
providerID: "test-provider",
modelID: "test-model",
variant: "unknown",
})
}),
)
it.effect("lowers selected Anthropic Session variants into Messages options", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
@@ -16,6 +16,7 @@ import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
@@ -83,19 +84,20 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(referenceGuidance),
Layer.provide(config),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
SessionExecution.Service,
SessionRunCoordinator.Service.pipe(
Effect.map((coordinator) =>
SessionExecution.Service.of({
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
}),
),
),
).pipe(Layer.provide(coordinator))
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
})
return SessionExecution.Service.of({
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
})
}),
).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
@@ -120,7 +122,6 @@ const it = testEffect(
skillGuidance,
config,
runner,
coordinator,
execution,
sessions,
),
+39 -357
View File
@@ -244,19 +244,20 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(referenceGuidance),
Layer.provide(config),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
SessionExecution.Service,
SessionRunCoordinator.Service.pipe(
Effect.map((coordinator) =>
SessionExecution.Service.of({
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
}),
),
),
).pipe(Layer.provide(coordinator))
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
})
return SessionExecution.Service.of({
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
})
}),
).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
@@ -283,7 +284,6 @@ const it = testEffect(
skillGuidance,
config,
runner,
coordinator,
execution,
sessions,
),
@@ -360,12 +360,12 @@ const setupOverflowRecovery = Effect.gen(function* () {
return session
})
const userTexts = (request: LLMRequest) =>
const messageTexts = (request: LLMRequest, role: "user" | "system") =>
request.messages.flatMap((message) =>
message.role === "user"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [],
)
const userTexts = (request: LLMRequest) => messageTexts(request, "user")
const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
const replaySessionProjection = (id: SessionV2.ID) =>
Effect.gen(function* () {
@@ -681,7 +681,6 @@ describe("SessionRunnerLLM", () => {
systemUnavailable = false
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) })
yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
@@ -746,39 +745,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not create a source Location epoch after a concurrent Session move", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
let moved = false
systemLoadHook = Effect.suspend(() => {
if (moved) return Effect.void
moved = true
return events
.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
expect(
yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get(),
).toBeUndefined()
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
}),
)
it.effect("reuses one durable baseline after the context producer changes", () =>
Effect.gen(function* () {
yield* setup
@@ -890,7 +856,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("composes selected-agent skill guidance and replaces it after an agent switch", () =>
it.effect("updates selected-agent skill guidance after an agent switch", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -913,12 +879,13 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nBuild skills"],
["Initial context\n\nReviewer skills"],
["Initial context\n\nBuild skills"],
])
expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills"))
}),
)
it.effect("retries first-epoch preparation when the selected agent changes during observation", () =>
it.effect("keeps the sampled agent when selection changes during observation", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -945,88 +912,12 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
["Initial context\n\nBuild skills"],
])
}),
)
it.effect("opens a queued activity once when the selected agent changes during observation", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
let switched = false
systemLoadHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
})
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Queued" }),
delivery: "queue",
resume: false,
})
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect((yield* session.context(sessionID)).filter((message) => message.type === "user")).toHaveLength(1)
}),
)
it.effect("retries an agent switch before the final provider-dispatch boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
let switched = false
modelResolveHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
])
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: null })
}),
)
it.effect("retries a model switch before the final provider-dispatch boundary", () =>
it.effect("keeps the sampled model when selection changes during model resolution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -1049,145 +940,11 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests.map((request) => request.model)).toEqual([replacementModel])
expect(requests.map((request) => request.model)).toEqual([model])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]])
}),
)
it.effect("fences an unchanged epoch read across an agent ABA replacement request", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
let switched = false
systemLoadHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
.pipe(
Effect.andThen(
events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(2),
agent: AgentV2.defaultID,
}),
),
Effect.asVoid,
)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
requests.length = 0
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: null })
}),
)
it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
const context = (text: string) =>
Effect.succeed(
SystemContext.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(text),
baseline: String,
update: (_previous, current) => current,
}),
)
const location = (yield* session.get(sessionID)).location
expect(
yield* SessionContextEpoch.prepare(
db,
events,
context("Stale build context"),
sessionID,
location,
AgentV2.defaultID,
).pipe(Effect.catchDefect(Effect.succeed)),
).toBeInstanceOf(SessionContextEpoch.AgentMismatch)
expect(
yield* SessionContextEpoch.prepare(
db,
events,
context("Reviewer context"),
sessionID,
location,
AgentV2.ID.make("reviewer"),
),
).toMatchObject({ baseline: "Reviewer context" })
}),
)
it.effect("blocks a cross-agent provider turn while replacement context is unavailable", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.defaultID, "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
requests.length = 0
const blocked = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(blocked)).toBe(true)
if (Exit.isFailure(blocked))
expect(Cause.squash(blocked.cause)).toBeInstanceOf(SessionContextEpoch.AgentReplacementBlocked)
expect(requests).toHaveLength(0)
systemUnavailable = false
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
])
}),
)
it.effect("admits removed context as a chronological System message", () =>
Effect.gen(function* () {
yield* setup
@@ -1209,7 +966,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () =>
it.effect("keeps the baseline and chronological System updates after a model switch", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -1235,24 +992,26 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
["Replacement context"],
["Initial context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"])
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
"user",
"user",
"system",
"model-switched",
"user",
"system",
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(5)
expect(yield* session.messages({ sessionID })).toHaveLength(6)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
yield* session.resume(sessionID)
}),
)
it.effect("defers replacement while admitted context is temporarily unavailable", () =>
it.effect("preserves the baseline while context is temporarily unavailable", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -1279,81 +1038,12 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
["Replacement context"],
["Initial context"],
])
}),
)
it.effect("advances a pending replacement to the latest invalidation boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(2),
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
})
const latest = yield* SessionInput.latestSeq(db, sessionID)
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: latest })
}),
)
it.effect("retries epoch preparation until observation-time invalidations settle", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
requests.length = 0
systemBaseline = "Changed context"
let invalidations = 0
systemLoadHook = Effect.suspend(() => {
if (invalidations === 4) return Effect.void
invalidations++
return events
.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(invalidations),
model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") },
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(invalidations).toBe(4)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"])
}),
)
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
it.effect("rebuilds the baseline directly after completed compaction", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -1580,7 +1270,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("preserves effective System updates while compaction replacement is blocked", () =>
it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -1613,16 +1303,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
expect(
requests
.at(-1)
?.messages.some(
(message) =>
message.role === "system" &&
message.content[0]?.type === "text" &&
message.content[0].text === "Changed context",
),
).toBe(true)
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
}),
)
@@ -1821,8 +1502,9 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Replacement context"],
["Initial context"],
])
expect(systemTexts(requests[1]!)).toContain("Replacement context")
}),
)
@@ -2478,7 +2160,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"])
yield* (yield* SessionRunCoordinator.Service).wake(sessionID)
yield* (yield* SessionExecution.Service).wake(sessionID)
yield* Effect.yieldNow
expect(requests).toHaveLength(2)
}),
@@ -2684,7 +2366,7 @@ describe("SessionRunnerLLM", () => {
})
requests.length = 0
yield* (yield* SessionRunCoordinator.Service).wake(sessionID)
yield* (yield* SessionExecution.Service).wake(sessionID)
yield* Effect.yieldNow
expect(requests).toHaveLength(1)
@@ -2734,7 +2416,7 @@ describe("SessionRunnerLLM", () => {
LLMEvent.finish({ reason: "stop" }),
]
yield* (yield* SessionRunCoordinator.Service).wake(sessionID)
yield* (yield* SessionExecution.Service).wake(sessionID)
while (requests.length === 0) yield* Effect.yieldNow
expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"])
+3 -3
View File
@@ -21,7 +21,7 @@ import { GeminiToolSchema } from "./utils/gemini-tool-schema"
import { Lifecycle } from "./utils/lifecycle"
const ADAPTER = "gemini"
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// =============================================================================
@@ -182,7 +182,7 @@ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) {
if (part.type === "text") return { text: part.text }
const media = yield* ProviderShared.validateMedia("Gemini", part, IMAGE_MIMES)
const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES)
return { inlineData: { mimeType: media.mime, data: media.base64 } }
})
@@ -275,7 +275,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
for (const item of content) {
if (item.type === "text") continue
const media = yield* ProviderShared.validateToolFile("Gemini", item, IMAGE_MIMES)
const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
}
}
+14 -4
View File
@@ -19,6 +19,7 @@ export { isRecord }
export const Json = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownSync(Json)
export const encodeJson = Schema.encodeSync(Json)
const isJson = Schema.is(Schema.Json)
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
@@ -188,8 +189,11 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`)
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
export const MAX_MEDIA_ENCODED_BYTES = 8 * 1024 * 1024
export const MAX_MEDIA_DECODED_BYTES = 6 * 1024 * 1024
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
@@ -240,8 +244,14 @@ export const validateToolFile = (route: string, part: ToolFileContent, supported
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
export const toolResultText = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
if (part.result.type === "content") return encodeJson(part.result.value)
if (part.result.type === "text") return String(part.result.value)
if (part.result.type === "error") {
const value = part.result.value
const prototype =
typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value)
const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null
return structured && isJson(value) ? encodeJson(value) : String(value)
}
return encodeJson(part.result.value)
}
@@ -224,6 +224,27 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }),
],
}),
)
expect(prepared.body.messages.at(-1)).toEqual({
role: "tool",
tool_call_id: "call_1",
content: ProviderShared.encodeJson(error),
})
}),
)
it.effect("continues image tool results as vision input without base64 text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
@@ -360,6 +360,64 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = {
error: { type: "unknown", message: "Tool execution interrupted" },
content: [],
structured: {},
}
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]),
Message.tool({
id: "call_1",
name: "bash",
resultType: "error",
result: error,
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error))
}),
)
it.effect("keeps primitive tool errors as plain text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }),
],
}),
)
expect(expectToolOutput(prepared.body).output).toBe("503")
}),
)
it.effect("keeps non-JSON tool errors as plain text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }),
],
}),
)
expect(expectToolOutput(prepared.body).output).toBe("Error: boom")
}),
)
// Regression: screenshot/read tool results must stay structured so base64
// image data is not JSON-stringified into `function_call_output.output`.
it.effect("lowers image tool-result content as structured input_image items", () =>
+54 -3
View File
@@ -41,14 +41,31 @@ export const AttachCommand = cmd({
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
}),
handler: async (args) => {
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
const directory = (() => {
if (!args.dir) return undefined
@@ -60,6 +77,40 @@ export const AttachCommand = cmd({
return args.dir
}
})()
if (args.mini) {
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: noReplay ? false : undefined,
replayLimit: args.replayLimit,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
+1 -1
View File
@@ -1,6 +1,6 @@
import type { CommandModule } from "yargs"
export type WithDoubleDash<T> = T & { "--"?: string[] }
export type WithDoubleDash<T> = T & { "--"?: string[]; _?: Array<string | number> }
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
return input
+82 -26
View File
@@ -1,13 +1,13 @@
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { FSUtil } from "@opencode-ai/core/fs-util"
// CLI entry point for `opencode run`.
// CLI entry point for `opencode run` and `opencode --mini`.
//
// Handles three modes:
// 1. Non-interactive (default): sends a single prompt, streams events to
// stdout, and exits when the session goes idle.
// 2. Interactive local (`--interactive`): boots the split-footer direct mode
// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode
// with an in-process server (no external HTTP).
// 3. Interactive attach (`--interactive --attach`): connects to a running
// 3. Interactive attach (`opencode --mini --attach`): connects to a running
// opencode server and runs interactive mode against it.
//
// Also supports `--command` for slash-command execution, `--format json` for
@@ -217,21 +217,22 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "show thinking blocks",
})
.option("mini", {
type: "boolean",
hidden: true,
default: false,
})
.option("replay", {
type: "boolean",
default: true,
hidden: true,
describe: "replay interactive session history on resume and after resize (use --no-replay to disable)",
})
.option("replay-limit", {
type: "number",
hidden: true,
describe: "cap visible interactive replay to the newest N messages",
})
.option("interactive", {
alias: ["i"],
type: "boolean",
describe: "run in direct interactive split-footer mode",
default: false,
})
.option("dangerously-skip-permissions", {
type: "boolean",
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
@@ -240,6 +241,7 @@ export const RunCommand = effectCmd({
.option("demo", {
type: "boolean",
default: false,
hidden: true,
describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately",
}),
handler: Effect.fn("Cli.run")(function* (args) {
@@ -252,7 +254,8 @@ export const RunCommand = effectCmd({
const localInstance = yield* InstanceRef
yield* Effect.promise(async () => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
const interactive = args.mini
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
const die = (message: string): never => {
UI.error(message)
process.exit(1)
@@ -269,20 +272,24 @@ export const RunCommand = effectCmd({
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
if (args.interactive && args.command) {
die("--interactive cannot be used with --command")
if (interactive && args.command) {
die("--mini cannot be used with --command")
}
if (args.demo && !args.interactive) {
die("--demo requires --interactive")
if (interactive && args._?.[0] !== "mini") {
die("--mini must be used without the run subcommand")
}
if (args.interactive && args.format === "json") {
die("--interactive cannot be used with --format json")
if (args.demo && !interactive) {
die("--demo requires --mini")
}
if (args["replay-limit"] !== undefined && !args.interactive) {
die("--replay-limit requires --interactive")
if (interactive && args.format === "json") {
die("--mini cannot be used with --format json")
}
if (args["replay-limit"] !== undefined && !interactive) {
die("--replay-limit requires --mini")
}
if (
@@ -292,11 +299,11 @@ export const RunCommand = effectCmd({
die("--replay-limit must be a positive integer")
}
if (args.interactive && !process.stdout.isTTY) {
die("--interactive requires a TTY stdout")
if (interactive && !process.stdout.isTTY) {
die("--mini requires a TTY stdout")
}
if (args.interactive) {
if (interactive) {
try {
resolveInteractiveStdin().cleanup?.()
} catch (error) {
@@ -304,7 +311,7 @@ export const RunCommand = effectCmd({
}
}
const replay = args.replay || args["replay-limit"] !== undefined
const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const directory = (() => {
@@ -393,7 +400,7 @@ export const RunCommand = effectCmd({
message = resolveRunInput(message, piped) ?? ""
const initialInput = resolveRunInput(rawMessage, piped)
if (message.trim().length === 0 && !args.command && !args.interactive) {
if (message.trim().length === 0 && !args.command && !interactive) {
UI.error("You must provide a message or a command")
process.exit(1)
}
@@ -403,7 +410,7 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const rules: PermissionV1.Ruleset = args.interactive
const rules: PermissionV1.Ruleset = interactive
? []
: [
{
@@ -801,7 +808,7 @@ export const RunCommand = effectCmd({
await share(client, sessionID)
if (!args.interactive) {
if (!interactive) {
const events = await client.event.subscribe()
const completed = loop(client, events).catch((e) => {
console.error(e)
@@ -875,7 +882,7 @@ export const RunCommand = effectCmd({
return
}
if (args.interactive && !args.attach && !args.session && !args.continue) {
if (interactive && !args.attach && !args.session && !args.continue) {
const model = pick(args.model)
const { runInteractiveLocalMode } = await import("./run/runtime")
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -933,3 +940,52 @@ export const RunCommand = effectCmd({
})
}),
})
type MiniCommandInput = {
directory?: string
attach?: string
password?: string
username?: string
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
prompt?: string
replay?: boolean
replayLimit?: number
demo?: boolean
}
export async function runMini(input: MiniCommandInput) {
if (!RunCommand.handler) throw new Error("Mini command handler is unavailable")
await RunCommand.handler({
$0: "opencode",
_: ["mini"],
message: input.prompt ? [input.prompt] : [],
command: undefined,
continue: input.continue,
session: input.session,
fork: input.fork,
share: undefined,
model: input.model,
agent: input.agent,
format: "default",
file: undefined,
title: undefined,
attach: input.attach,
password: input.password,
username: input.username,
dir: input.directory,
port: undefined,
variant: undefined,
thinking: undefined,
mini: true,
replay: input.replay ?? true,
"replay-limit": input.replayLimit,
replayLimit: input.replayLimit,
"dangerously-skip-permissions": false,
dangerouslySkipPermissions: false,
demo: input.demo ?? false,
})
}
@@ -1,7 +1,7 @@
import fs from "fs"
import * as tty from "node:tty"
export const INTERACTIVE_INPUT_ERROR = "--interactive requires a controlling terminal for input"
export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input"
type InteractiveStdin = {
stdin: NodeJS.ReadStream
+1 -1
View File
@@ -1,4 +1,4 @@
// Top-level orchestrator for `run --interactive`.
// Top-level orchestrator for `opencode --mini`.
//
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
// and prompt queue together into a single session loop. Two entry points:
+1 -1
View File
@@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
lines,
body_left + label.length,
top + 1,
`opencode run -i -s ${meta.session_id}`,
`opencode --mini -s ${meta.session_id}`,
right,
undefined,
TextAttributes.BOLD,
+1 -1
View File
@@ -1,4 +1,4 @@
// Shared type vocabulary for the direct interactive mode (`run --interactive`).
// Shared type vocabulary for the direct interactive mode (`opencode --mini`).
//
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
// session transcript, and a mutable footer for prompt input, status, and
+65
View File
@@ -103,8 +103,73 @@ export const TuiThreadCommand = cmd({
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
})
.option("demo", {
type: "boolean",
hidden: true,
}),
handler: async (args) => {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
if (args.mini) {
const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) =>
process.argv.some((arg) => arg === option || arg.startsWith(option + "=")),
)
if (network) {
UI.error(`${network} cannot be used with --mini`)
process.exitCode = 1
return
}
const { runMini } = await import("./run")
await runMini({
directory: resolveThreadDirectory(args.project),
continue: args.continue,
session: args.session,
fork: args.fork,
model: args.model,
agent: args.agent,
prompt: args.prompt,
replay: noReplay ? false : undefined,
replayLimit: args.replayLimit,
demo: args.demo,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
["--demo", args.demo !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const unguard = win32InstallCtrlCGuard()
try {
const { TuiConfig } = await import("@/config/tui")
@@ -13,6 +13,7 @@ const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"])
interface PkceCodes {
verifier: string
@@ -370,6 +371,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
Object.entries(provider.models)
.filter(([, model]) => {
if (ALLOWED_MODELS.has(model.api.id)) return true
if (DISALLOWED_MODELS.has(model.api.id)) return false
const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
return match ? parseFloat(match[1]) > 5.4 : false
})
@@ -281,7 +281,7 @@ export function createRoutes(
]),
Layer.provide(LayerNode.buildLayer(app)),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provide(Observability.layer),
Layer.provideMerge(Observability.layer),
)
}
@@ -50,17 +50,21 @@ Positionals:
url http://localhost:4096 [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory to run in [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]"
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory to run in [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
[string]
--mini start the minimal interactive interface [boolean] [default: false]
--no-replay disable mini session history replay on resume and after resize [boolean]
--replay-limit cap visible mini replay to the newest N messages [number]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = `
@@ -103,16 +107,8 @@ Options:
--variant model variant (provider-specific reasoning effort, e.g., high,
max, minimal) [string]
--thinking show thinking blocks [boolean]
--replay replay interactive session history on resume and after resize
(use --no-replay to disable) [boolean] [default: true]
--replay-limit cap visible interactive replay to the newest N messages
[number]
-i, --interactive run in direct interactive split-footer mode
[boolean] [default: false]
--dangerously-skip-permissions auto-approve permissions that are not explicitly denied
(dangerous!) [boolean] [default: false]
--demo enable direct interactive demo slash commands; pass one as the
message to run it immediately [boolean] [default: false]"
(dangerous!) [boolean] [default: false]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = `
@@ -102,6 +102,10 @@ describe("opencode CLI help-text snapshots", () => {
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
expect(topLevel.exitCode).toBe(0)
expect(topLevel.stderr.endsWith(EOL)).toBe(true)
expect(topLevel.stderr).toContain("--mini")
expect(topLevel.stderr).not.toContain("--thinking")
expect(topLevel.stderr).not.toContain("--variant")
expect(topLevel.stderr).not.toContain("--demo")
const argvs: Array<readonly string[]> = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS]
+60 -1
View File
@@ -1,8 +1,11 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import yargs from "yargs"
import { tmpdir } from "../../fixture/fixture"
import { resolveThreadDirectory } from "../../../src/cli/cmd/tui"
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
import { cliIt } from "../../lib/cli-process"
describe("tui thread", () => {
test("loads the TUI integration lazily", async () => {
@@ -33,4 +36,60 @@ describe("tui thread", () => {
test("uses the real cwd after resolving a relative project from PWD", async () => {
await check(".")
})
test("resolves a relative mini project from PWD when cwd differs", async () => {
await using pwd = await tmpdir({ git: true })
await using cwd = await tmpdir({ git: true })
expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(pwd.path)
expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path)
})
test("parses supported --no-replay forms", async () => {
for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) {
const args = await yargs([])
.command({ ...TuiThreadCommand, handler: () => {} })
.exitProcess(false)
.parse(["--mini", option, "--replay-limit", "10"])
expect(args.replay === false || args.noReplay === true).toBe(true)
expect(args.replayLimit).toBe(10)
}
})
test("preserves boolean negation for existing options", async () => {
const args = await yargs([])
.command({ ...TuiThreadCommand, handler: () => {} })
.exitProcess(false)
.parse(["--mdns", "--no-mdns"])
expect(args.mdns).toBe(false)
})
cliIt.live("rejects mini-only options without --mini", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["--replay-limit", "10"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("--replay-limit requires --mini")
}),
)
cliIt.live("routes attached sessions to mini mode", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("--mini requires a TTY stdout")
}),
)
cliIt.live("rejects network options in mini mode", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["--mini", "--port", "4096"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("--port cannot be used with --mini")
}),
)
})
@@ -559,21 +559,28 @@ describe("plugin.openai.ws-pool", () => {
})
test("retries failed websocket streams before using HTTP fallback", async () => {
const attempts: Array<(socket: WebSocket) => void> = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
attempts.shift()?.(socket)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
streamRetries: 1,
})
const firstAttempt = new Promise<WebSocket>((resolve) => attempts.push(resolve))
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
const firstSocket = await firstAttempt
firstSocket.terminate()
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const secondAttempt = new Promise<WebSocket>((resolve) => attempts.push(resolve))
const second = await fetch(server.url, streamRequest())
expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket")
const secondSocket = await secondAttempt
secondSocket.terminate()
expect((await readTextError(second.text())).message).toContain("WebSocket closed before response.completed")
const third = await fetch(server.url, streamRequest())
expect(await third.text()).toBe("http")
@@ -1,4 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Context, Schema } from "effect"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
@@ -19,14 +21,9 @@ function request(route: string, directory: string, init: RequestInit = {}) {
}
const Event = Schema.Struct({
id: Schema.String,
id: EventV2.ID,
type: Schema.String,
location: Schema.optional(
Schema.Struct({
directory: Schema.String,
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
}),
),
location: Schema.optional(Location.Ref),
data: Schema.Unknown,
})
@@ -50,6 +47,17 @@ afterEach(async () => {
})
describe("v2 location HttpApi", () => {
test("decodes EventV2 location refs without resolved project metadata", () => {
expect(
Schema.decodeUnknownSync(Event)({
id: "evt_test",
type: "file.watcher.updated",
location: { directory: "/tmp/project" },
data: {},
}),
).toMatchObject({ location: { directory: "/tmp/project" } })
})
test("returns command and skill snapshots with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
@@ -79,7 +87,7 @@ describe("v2 location HttpApi", () => {
expect(created.status).toBe(200)
expect(await readEventType(reader, "session.created")).toMatchObject({
type: "session.created",
location: { directory: publisher.path, project: { directory: publisher.path } },
location: { directory: publisher.path },
data: { sessionID: expect.any(String) },
})
await reader.cancel()
@@ -1711,11 +1711,17 @@ unixNoLLMServer(
withSh(() =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
const { directory: dir } = yield* TestInstance
const afs = yield* FSUtil.Service
const ready = path.join(dir, ".shell-ready")
const sh = yield* prompt
.shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
.shell({ sessionID: chat.id, agent: "build", command: ": > '.shell-ready'; sleep 30" })
.pipe(Effect.forkChild)
yield* waitForBusy(chat.id)
yield* pollWithTimeout(
afs.existsSafe(ready).pipe(Effect.map((exists) => (exists ? (true as const) : undefined))),
"shell never created readiness marker",
)
yield* prompt.cancel(chat.id)
+1 -54
View File
@@ -21,7 +21,6 @@ export type Event =
| EventSessionNextPrompted
| EventSessionNextPromptAdmitted
| EventSessionNextPromptPromoted
| EventSessionNextInterruptRequested
| EventSessionNextContextUpdated
| EventSessionNextSynthetic
| EventSessionNextShellStarted
@@ -876,14 +875,6 @@ export type GlobalEvent = {
timeCreated: number
}
}
| {
id: string
type: "session.next.interrupt.requested"
properties: {
timestamp: number
sessionID: string
}
}
| {
id: string
type: "session.next.context.updated"
@@ -1638,7 +1629,6 @@ export type GlobalEvent = {
| SyncEventSessionNextPrompted
| SyncEventSessionNextPromptAdmitted
| SyncEventSessionNextPromptPromoted
| SyncEventSessionNextInterruptRequested
| SyncEventSessionNextContextUpdated
| SyncEventSessionNextSynthetic
| SyncEventSessionNextShellStarted
@@ -2781,7 +2771,6 @@ export type V2Event =
| V2EventSessionNextPrompted
| V2EventSessionNextPromptAdmitted
| V2EventSessionNextPromptPromoted
| V2EventSessionNextInterruptRequested
| V2EventSessionNextContextUpdated
| V2EventSessionNextSynthetic
| V2EventSessionNextShellStarted
@@ -3249,21 +3238,6 @@ export type SyncEventSessionNextPromptPromoted = {
}
}
export type SyncEventSessionNextInterruptRequested = {
type: "sync"
id: string
syncEvent: {
type: "session.next.interrupt.requested.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
}
}
}
export type SyncEventSessionNextContextUpdated = {
type: "sync"
id: string
@@ -3663,7 +3637,7 @@ export type SyncEventSessionNextCompactionEnded = {
type: "sync"
id: string
syncEvent: {
type: "session.next.compaction.ended.2"
type: "session.next.compaction.ended.1"
id: string
seq: number
aggregateID: string
@@ -4570,24 +4544,6 @@ export type V2EventSessionNextPromptPromoted = {
}
}
export type V2EventSessionNextInterruptRequested = {
id: string
metadata?: {
[key: string]: unknown
}
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
type: "session.next.interrupt.requested"
data: {
timestamp: number
sessionID: string
}
}
export type V2EventSessionNextContextUpdated = {
id: string
metadata?: {
@@ -6224,15 +6180,6 @@ export type EventSessionNextPromptPromoted = {
}
}
export type EventSessionNextInterruptRequested = {
id: string
type: "session.next.interrupt.requested"
properties: {
timestamp: number
sessionID: string
}
}
export type EventSessionNextContextUpdated = {
id: string
type: "session.next.context.updated"
+1 -169
View File
@@ -14689,9 +14689,6 @@
{
"$ref": "#/components/schemas/EventSessionNextPromptPromoted"
},
{
"$ref": "#/components/schemas/EventSessionNextInterruptRequested"
},
{
"$ref": "#/components/schemas/EventSessionNextContextUpdated"
},
@@ -17297,35 +17294,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"type": {
"type": "string",
"enum": ["session.next.interrupt.requested"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["timestamp", "sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
@@ -19867,9 +19835,6 @@
{
"$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextInterruptRequested"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextContextUpdated"
},
@@ -23111,9 +23076,6 @@
{
"$ref": "#/components/schemas/V2EventSessionNextPromptPromoted"
},
{
"$ref": "#/components/schemas/V2EventSessionNextInterruptRequested"
},
{
"$ref": "#/components/schemas/V2EventSessionNextContextUpdated"
},
@@ -24498,56 +24460,6 @@
"required": ["type", "id", "syncEvent"],
"additionalProperties": false
},
"SyncEventSessionNextInterruptRequested": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sync"]
},
"id": {
"type": "string",
"pattern": "^evt_"
},
"syncEvent": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["session.next.interrupt.requested.1"]
},
"id": {
"type": "string",
"pattern": "^evt_"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["timestamp", "sessionID"],
"additionalProperties": false
}
},
"required": ["type", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
}
},
"required": ["type", "id", "syncEvent"],
"additionalProperties": false
},
"SyncEventSessionNextContextUpdated": {
"type": "object",
"properties": {
@@ -25832,7 +25744,7 @@
"properties": {
"type": {
"type": "string",
"enum": ["session.next.compaction.ended.2"]
"enum": ["session.next.compaction.ended.1"]
},
"id": {
"type": "string",
@@ -28771,57 +28683,6 @@
"required": ["id", "type", "data"],
"additionalProperties": false
},
"V2EventSessionNextInterruptRequested": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"metadata": {
"type": "object"
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer"
},
"version": {
"type": "integer"
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/LocationRef"
},
"type": {
"type": "string",
"enum": ["session.next.interrupt.requested"]
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["timestamp", "sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "data"],
"additionalProperties": false
},
"V2EventSessionNextContextUpdated": {
"type": "object",
"properties": {
@@ -33460,35 +33321,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextInterruptRequested": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"type": {
"type": "string",
"enum": ["session.next.interrupt.requested"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["timestamp", "sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextContextUpdated": {
"type": "object",
"properties": {
+8 -4
View File
@@ -744,10 +744,14 @@ function TopModelsChart(props: {
data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"}
>
<strong>{point().date}</strong>
<span>
{formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())}
</span>
<div data-slot="tooltip-divider" />
<Show when={metric() === "tokens"}>
<span>
{formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())}
</span>
</Show>
<Show when={metric() === "tokens"}>
<div data-slot="tooltip-divider" />
</Show>
<For each={visibleTopModelsSegments(point())}>
{(item) => (
<p
+32 -1
View File
@@ -1,12 +1,23 @@
# V2 Schema Changelog
## 2026-06-22: Reset Unpublished Compaction Event
- Replace the unpublished `session.next.compaction.ended.1` payload with the current checkpoint payload and remove its legacy decoder.
- Reset experimental events, sequences, Session inputs, projected Session messages, Context Epochs, synchronized workspace rows, and Session workspace links.
- Preserve canonical V1 `session`, `message`, and `part` rows.
## 2026-06-22: Make Session Interruption Process-Local
- Remove the unprojected `session.next.interrupt.requested.1` event from the experimental durable Session event union and generated SDK.
- No canonical V1 data requires migration; experimental V2 event history containing the retired event is disposable.
## 2026-06-05: Execute Automatic Session Compaction
- Trigger automatic compaction before provider turns using the complete estimated request and absolute model-aware headroom.
- Preserve the existing structured summary contract and update prior summaries with newly compacted history.
- Store token-bounded recent history as plain serialized text inside the checkpoint instead of replaying provider-native messages.
- Keep compaction starts durable and progress deltas live-only; activate history cutover only from a durable completed summary.
- Version the completed event as `session.next.compaction.ended.2` rather than changing the existing synchronized v1 payload in place.
- Store the completed event with the current checkpoint payload containing stable message identity, reason, summary, and recent context.
- Reload the replacement Context Epoch and continue the original pending turn after compaction.
- Preserve full durable history; compaction changes only the active model representation.
- Defer provider-overflow recovery, explicit manual compaction, and deterministic old tool-result pruning.
@@ -800,3 +811,23 @@ Change:
Compatibility:
- Existing Context Epoch rows backfill the default `build` agent and reconcile to another selected agent at the next safe provider-turn boundary.
## 2026-06-22: Simplify Session Context Rebaselining
Affected schema:
- Remove `session_context_epoch.agent`, `session_context_epoch.replacement_seq`, and `session_context_epoch.revision`.
- No synchronized event, public HTTP API, or generated SDK schema changes.
Change:
- Sample the effective agent and model once for each provider turn; selection changes apply to the next turn.
- Preserve the immutable baseline and admit ordinary System Context changes as chronological `ContextUpdated` messages.
- Rebuild the baseline directly after completed compaction instead of maintaining pending replacement state.
- Preserve the old baseline and its effective chronological updates while a post-compaction baseline cannot be rendered completely.
- Rely on the process-local Session execution lane instead of optimistic concurrency state between Context Epoch writers.
Compatibility:
- Existing Context Epoch rows migrate in place by dropping the obsolete selection and pending-replacement columns.
- Model and agent switches no longer discard earlier chronological System Context updates by forcing a new baseline.
+12 -15
View File
@@ -20,10 +20,10 @@ sessions.prompt({ id?, sessionID, prompt, delivery?, resume? })
-> resume false admits only
sessions.interrupt(sessionID)
-> interrupts the active ownership chain on this process
-> waits for active drain cleanup and settlement
-> suppresses reruns already queued before interruption
-> preserves durable inbox rows for a later fresh wake or resume
-> interrupts active execution on this process
-> waits for runner cleanup and settlement
-> clears a coalesced follow-up wake already registered with this coordinator
-> preserves durable inbox rows for a later wake or resume
-> idle or missing Session is a no-op
```
@@ -46,7 +46,7 @@ Projected hosted tools preserve call-side and settlement-side provider metadata
## Context Epochs
V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one effective agent, one immutable baseline, and a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission.
V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch stores one immutable provider-cache baseline and a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission.
The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically.
@@ -72,7 +72,7 @@ Client Runner System Context Registry C
│ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶
```
Agent switches, model switches, and completed compactions request lazy baseline replacement. A switch admitted after the current safe provider-turn boundary applies to the next provider turn while leaving the already-prepared baseline durable. Before another cross-agent provider turn, the replacement must complete; unavailable admitted context blocks instead of exposing the prior agent's privileged baseline. A Session move clears the epoch so the destination Location must initialize a complete baseline before another provider turn. Epoch creation and replacement are fenced against the authoritative Session Location/effective agent and the epoch revision, preventing stale or ABA-observed context from becoming durable.
Agent and model selection are provider-turn scoped. A switch admitted after the current safe provider-turn boundary applies to the next provider turn without restarting the current turn or replacing the baseline. Agent-specific skill guidance remains a Context Source, so changed guidance is admitted as a chronological System message. A completed compaction causes the next provider attempt to render a fresh baseline directly from current complete context. A Session move clears the epoch so the destination Location initializes a complete baseline on its next run.
```text
Session Epoch
@@ -83,11 +83,8 @@ Session Epoch
│ │ reconcile chronological update │
│ ◀─────────────────────────────────╯
│ │
├─ request replacement ───────────▶
│ ├─────────────────────────────────────╮
│ │ replace after complete observation │
│ ◀─────────────────────────────────────╯
├─ completed compaction ──────────▶
├─ render fresh baseline
│ │
├─ clear after Location move ─────▶
```
@@ -110,7 +107,7 @@ Before each provider turn, the runner estimates the complete model-visible reque
Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes.
`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.2` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message and requests Context Epoch replacement. A failed or interrupted attempt therefore leaves the previous history boundary active.
`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active.
Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn.
@@ -138,7 +135,7 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o
| Per-turn request assembly | Plugin message, system, parameter, and header transforms | missing | Design V2 plugin hooks and lifecycle semantics. |
| Per-turn request assembly | Model variants and request settings | partial | Apply effective agent options and future plugin-mutated request settings. |
| Per-turn request assembly | Structured-output policy | missing | Add prompt format, generated tool, tool choice, and model-visible policy together. |
| Per-turn request assembly | Automatic/context-pressure compaction | partial | V2 replays completed compactions and replaces epochs but cannot initiate compaction. |
| Per-turn request assembly | Automatic/context-pressure compaction | complete | V2 initiates automatic and overflow-triggered compaction, then rebuilds the baseline from the completed checkpoint. |
| Prompt/reference expansion | Durable typed prompt attachments | complete | None. |
| Prompt/reference expansion | Native template and `@` mention expansion | missing | Parse and resolve native V2 prompt input before durable admission. |
| Prompt/reference expansion | File, directory, media, and MCP-resource materialization | partial | Materialize and normalize sources instead of lowering unresolved attachment metadata. |
@@ -155,12 +152,12 @@ Inbox delivery is explicit:
Execution has two entry points:
- `run` is an explicit resume. It joins an active drain chain or starts one, and performs at least one provider attempt even when no input is eligible.
- `run` is an explicit resume. It joins any active execution or starts a forced drain while idle. A forced drain bypasses the no-eligible-input guard, but preparation may still fail before a provider attempt.
- `wake` reports newly recorded durable inbox work. Repeated wakes coalesce. A wake calls the provider only when it can promote eligible input.
Post-crash activity recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model durable activity identity, provider-dispatch ambiguity, required continuation, queue-opener reservation, retry policy, and visible recovery status together.
A process-global `SessionRunCoordinator` serializes each local Session drain chain while allowing different Sessions to drain concurrently. It enters the Session's current Location only when a drain starts, so interruption targets process execution ownership rather than Location cache identity. Interruption establishes a local ownership-chain boundary by stopping the current chain while preserving pending/unpromoted durable inbox rows for a later fresh wake and projected history for explicit resume. A Location runner also fences every new provider turn against its captured Location so a moved Session cannot begin another turn through source-Location tools or context. An already-dispatched provider turn may still settle source-Location calls until a future move-control slice interrupts active ownership. Automatic startup discovery, durable multi-node ownership, stale-owner fencing, and retry policy remain future work.
A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location.
Inbox promotion coalesces pending steers in durable admission order and opens one queued activity at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth.
+3 -12
View File
@@ -33,12 +33,7 @@ through legacy `SessionPrompt.loop(...)`:
Prompt admission now uses a durable `session_input` inbox rather than immediate
transcript projection. `steer` inputs coalesce into the active activity at the
next safe provider-turn boundary. `queue` inputs form a FIFO of future activities
that open one at a time. A process-global `SessionRunCoordinator` coalesces process-local wakeups
around settlement races. Explicit `run` resumes perform at least one provider
attempt; advisory `wake` notifications call the provider only for eligible inbox
work. Steers coalesce into the active activity at
safe provider boundaries; queued inputs open later activities one at a time in
FIFO order.
that open one at a time.
Next reviewed slices:
@@ -55,8 +50,8 @@ Next reviewed slices:
- integrate the new BackgroundJob service with V2 tool execution: support background
bash jobs and background agent dispatch with durable status observation,
completion delivery, and explicit cancellation / continuation semantics
- add compaction, durable/clustered interruption, retries, and stale-owner fencing
only as their slices become concrete
- add durable/clustered interruption, retries, and stale-owner fencing only as
their slices become concrete
### Deferred durable activity recovery
@@ -75,10 +70,6 @@ Design post-crash activity recovery as one explicit slice. It should model:
- retry budget, backoff, visible recovery status, startup discovery, and future
clustered ownership fencing
## Rework compaction - Aiden?
The new agent loop needs to trigger compaction properly
## Plugin API design - James?
We need to figure out how we want server plugins to work and what hooks are useful.