diff --git a/CONTEXT.md b/CONTEXT.md index 1c12ba641c..faf8ce9d12 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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`. diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index e34c3e750b..46a0f17fcd 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -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") diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 4355c18818..bd18d44503 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -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(), diff --git a/packages/core/schema.json b/packages/core/schema.json index c041a4e011..d0eeeebd5c 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -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, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1e915bb3cf..19b1b56843 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -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[] diff --git a/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts b/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts new file mode 100644 index 0000000000..1520bac4c1 --- /dev/null +++ b/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts @@ -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 diff --git a/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts new file mode 100644 index 0000000000..b771a64bb7 --- /dev/null +++ b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts @@ -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 diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 5c044ec60f..ed60fde6c5 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -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 ); `) diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 7388705d8c..7d32556340 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -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()("@opencode/public/OpenCode") {} -class SessionModelValidation extends Context.Service< - SessionModelValidation, - { - readonly validate: ( - input: Session.SwitchModelInput & { readonly location: Session.Info["location"] }, - ) => Effect.Effect - } ->()("@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. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts index 2610cec004..212583b559 100644 --- a/packages/core/src/public/session.ts +++ b/packages/core/src/public/session.ts @@ -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()( - "Session.ModelUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - }, -) {} - -export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "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 readonly list: (input?: ListInput) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: ( - input: SwitchModelInput, - ) => Effect.Effect + readonly switchModel: (input: SwitchModelInput) => Effect.Effect /** 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 readonly messages: (input: MessagesInput) => Effect.Effect diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 7454e0fa75..5dae699733 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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)), ), }) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 1fb8df92e6..18624706a9 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -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()( - "SessionContextEpoch.AgentReplacementBlocked", - { sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID }, -) {} - -const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => - 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, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, ): Effect.Effect { - 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, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, -): Effect.Effect { - return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe( - Effect.withSpan("SessionContextEpoch.prepare"), - ) +): Effect.Effect { + 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, 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, 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") }) diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 5eaf037168..d4b773d18b 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -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, diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index 9a99145bfb..a08912e956 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -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 - /** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */ - readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect + /** Registers newly recorded work. Repeated wakeups may coalesce. */ + readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect /** Interrupt active work owned by this process. Idle interruption is a no-op. */ - readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect + readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect } /** Routes execution from a Session ID to the runner owned by that Session's Location. */ diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 8f1b1763a0..7e0e3ca700 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -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({ - drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) { + const coordinator = yield* SessionRunCoordinator.make({ + 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({ diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 285c1bcd5c..fb55ab0756 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -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)) diff --git a/packages/core/src/session/logging.ts b/packages/core/src/session/logging.ts deleted file mode 100644 index c579ec15dc..0000000000 --- a/packages/core/src/session/logging.ts +++ /dev/null @@ -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, -) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID })) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index cf1eb2cedf..2c836dcd01 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -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({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index bffe4e74c6..2c87dfb8df 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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)) }), ) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index d52b63e8f1..cfab42300e 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -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 { - /** Starts or joins one explicit drain generation. */ - readonly run: (key: Key) => Effect.Effect - /** Coalesces one wake-up after durable work is recorded. */ - readonly wake: (key: Key, seq?: number) => Effect.Effect - /** Waits until the current ownership chain settles. */ - readonly awaitIdle: (key: Key) => Effect.Effect - /** Interrupts the active ownership chain without automatically draining pending wakes. */ - readonly interrupt: (key: Key, seq?: number) => Effect.Effect +/** Serializes execution for each key while allowing different keys to run concurrently. */ +export interface Coordinator { + /** Starts execution while idle or joins the active execution. */ + readonly run: (key: Key) => Effect.Effect + /** Registers one coalesced follow-up after newly recorded work. */ + readonly wake: (key: Key) => Effect.Effect + /** Stops active execution and waits for its cleanup. */ + readonly interrupt: (key: Key) => Effect.Effect } -/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */ -type Entry = { - readonly done: Deferred.Deferred - readonly settled: Deferred.Deferred> - current: Demand - pending?: Demand - explicitWaiter?: Deferred.Deferred - interruptSeq?: number +type Entry = { + readonly done: Deferred.Deferred owner?: Fiber.Fiber + 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 = (options: { - readonly drain: (key: Key, mode: Mode) => Effect.Effect - readonly onFailure?: (key: Key, cause: Cause.Cause) => Effect.Effect -}): Effect.Effect, never, Scope.Scope> => +export const make = (options: { + readonly drain: (key: Key, force: boolean) => Effect.Effect +}): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const active = new Map>() - const interruptSeq = new Map() - const report = yield* FiberSet.makeRuntime() + const active = new Map>() const fork = yield* FiberSet.makeRuntime() - const shutdown = Deferred.makeUnsafe() - 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): Entry => ({ - done: Deferred.makeUnsafe(), - settled: Deferred.makeUnsafe>(), - current, - explicitWaiter, + const makeEntry = (): Entry => ({ + done: Deferred.makeUnsafe(), + pendingWake: false, stopping: false, }) - const start = (key: Key, entry: Entry, demand: Demand, successor = false) => { + const start = (key: Key, entry: Entry, force: boolean, successor = false) => { const ready = Deferred.makeUnsafe() - 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 = (options: { if (!successor) Deferred.doneUnsafe(ready, Effect.void) } - const settle = (key: Key, entry: Entry, demand: Demand, exit: Exit.Exit) => { - 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, exit: Exit.Exit) => { + 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 => + 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 => - Effect.gen(function* () { - let firstFailure: Cause.Cause | 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 => + const interrupt = (key: Key): Effect.Effect => 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 { - 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() - 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): Effect.Effect { - return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt))) - } - - function acceptsWake(entry: Entry, 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, 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 {} - -export class Service extends Context.Service()("@opencode/v2/SessionRunCoordinator") {} - -export const layer = Layer.effect( - Service, - SessionRunner.Service.pipe( - Effect.flatMap((runner) => - make({ - drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }), - onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause), - }), - ), - Effect.map(Service.of), - ), -) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 4060cc6b04..634075dd91 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -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 } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 5d84e985a6..ddd2bf4e15 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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() 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 = []) => 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++) { diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 787c62c190..968933a6b5 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -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()( + "SessionRunnerModel.ModelUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + }, +) {} + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.VariantUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + variant: ModelV2.VariantID, + }, +) {} + export class UnsupportedApiError extends Schema.TaggedErrorClass()( "SessionRunnerModel.UnsupportedApiError", { @@ -33,7 +50,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass Effect.Effect @@ -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 => { 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, ) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index ca3d8e1b53..a9499554b4 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -170,9 +170,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", { .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), baseline: text().notNull(), - agent: text().$type().notNull().default(AgentV2.defaultID), snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), - replacement_seq: integer(), - revision: integer().notNull().default(0), }) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index d7126f76e2..835f41931d 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -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 = (effect: Effect.Effect) => @@ -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* () { diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 21acc40ee7..0b3e0c8e54 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -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()), diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index c5f90e92c4..fb9397e572 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/packages/core/test/public-opencode.test.ts @@ -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" }], - }, - }, - }, - }, - }), - ), - ) diff --git a/packages/core/test/session-logging.test.ts b/packages/core/test/session-logging.test.ts deleted file mode 100644 index 3d6cff2e4e..0000000000 --- a/packages/core/test/session-logging.test.ts +++ /dev/null @@ -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> = [] - 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") - }) - } -}) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index c84a3ab304..166b5deed1 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -20,9 +20,7 @@ import { testEffect } from "./lib/effect" const executionCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = [] -const interruptSeqs: Array = [] const wakeCalls: SessionV2.ID[] = [] -const wakeSeqs: Array = [] 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([]) }), ) }) diff --git a/packages/core/test/session-run-coordinator.test.ts b/packages/core/test/session-run-coordinator.test.ts index 39fb5779d3..909ba64870 100644 --- a/packages/core/test/session-run-coordinator.test.ts +++ b/packages/core/test/session-run-coordinator.test.ts @@ -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() + const gate = yield* Deferred.make() + const forces: boolean[] = [] + const coordinator = yield* SessionRunCoordinator.make({ + 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() @@ -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() - const gate = yield* Deferred.make() - const interrupted = yield* Deferred.make() - 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() + const firstGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() 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() - const secondStarted = yield* Deferred.make() - const secondGate = yield* Deferred.make() - const secondInterrupted = yield* Deferred.make() - 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() - const interrupted = yield* Deferred.make() - 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() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - 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() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - 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() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - 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() - 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() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const runSettled = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - 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() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - 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() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - 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 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() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const runSettled = yield* Deferred.make() - const idleSettled = yield* Deferred.make() - const interruptSettled = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - 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() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - 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() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - 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 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() - 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() - const secondGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const idleSettled = yield* Deferred.make() - 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() - const secondGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - 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() const secondStarted = yield* Deferred.make() const secondGate = yield* Deferred.make() + const thirdStarted = yield* Deferred.make() 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() + const interrupted = yield* Deferred.make() + 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() + const cleanupStarted = yield* Deferred.make() + const cleanupGate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + 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() + const cleanupStarted = yield* Deferred.make() + const cleanupGate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const forces: boolean[] = [] + const coordinator = yield* SessionRunCoordinator.make({ + 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() + const secondStarted = yield* Deferred.make() 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() - const wakeGate = yield* Deferred.make() - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - 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() - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const forcedStarted = yield* Deferred.make() - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - 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() - const wakeGate = yield* Deferred.make() - const runStarted = yield* Deferred.make() - const runGate = yield* Deferred.make() - const advisoryStarted = yield* Deferred.make() - const failure = new Error("explicit run failed") - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - 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() - 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() @@ -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[] = [] - const reportedOnce = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - 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({ - 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>() - const coordinator = yield* SessionRunCoordinator.make({ - 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() - const reported: Cause.Cause[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - 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() let runs = 0 let wake: (key: string) => Effect.Effect = () => Effect.void - const coordinator = yield* SessionRunCoordinator.make({ + const coordinator = yield* SessionRunCoordinator.make({ 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) }), diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 50e60a3616..e67ade131b 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -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" }, [ diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 91d7a24475..65e90cb6d0 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -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({ + 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, ), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 862bb56d33..6e97ab7939 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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({ + 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"]) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c8fa34b509..3a2311c8fd 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -21,7 +21,7 @@ import { GeminiToolSchema } from "./utils/gemini-tool-schema" import { Lifecycle } from "./utils/lifecycle" const ADAPTER = "gemini" -const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) +const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" // ============================================================================= @@ -182,7 +182,7 @@ const lowerToolConfig = (toolChoice: NonNullable) => 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 } }) } } diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 4a1fed5539..c5b6003fd2 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -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 = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (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) } diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9966b92e3d..5dbc89f1ae 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -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( + 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( diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 717a7e8024..b854537fe2 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -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( + 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( + 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( + 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", () => diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 278cee8a70..6f5aea6a12 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -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() diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index 05af009b88..910787f940 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -export type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[]; _?: Array } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 958632776b..fad09c3a7a 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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, + }) +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts index dad46a7fb0..d236fb02c2 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 65cd15f1ad..90cddffa22 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -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: diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/opencode/src/cli/cmd/run/splash.ts index 20194b95ce..141ff6fc55 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/opencode/src/cli/cmd/run/splash.ts @@ -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, diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 62e1a2d8a4..a914922e48 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 68941e976a..329874791d 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -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") diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 93c22ea6af..c13a9c439d 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -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 }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 09e25de8cb..b74df8deb8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -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), ) } diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index a672d2acae..25a4c38f90 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -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`] = ` diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index edd92120ad..a2626b113d 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -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 = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS] diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index f79fd40da7..73f87d904b 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -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") + }), + ) }) diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index cdcded9da6..7a125824e0 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -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((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((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") diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index 503ecb78ec..e178062e7a 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -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() diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5cd97f78e8..9c22e00410 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -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) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b2900e8d61..b15ff93470 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d9aee60510..0d8dc1aecf 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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": { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 8e4b86a00a..51ca059f8e 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -744,10 +744,14 @@ function TopModelsChart(props: { data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"} > {point().date} - - {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} - -
+ + + {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} + + + +
+ {(item) => (

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. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 002139cdd5..893b1dc2dd 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -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.