diff --git a/bun.lock b/bun.lock index 34bdcf98db..97c8362063 100644 --- a/bun.lock +++ b/bun.lock @@ -218,19 +218,21 @@ "version": "0.1.0", "dependencies": { "@daytonaio/sdk": "latest", - "@effect/ai": "^0.33.2", - "@effect/ai-anthropic": "^0.23.0", - "@effect/experimental": "^0.58.0", - "@effect/platform": "latest", + "@effect/ai": "0.33.2", + "@effect/ai-anthropic": "0.23.0", + "@effect/cluster": "0.56.4", + "@effect/experimental": "0.58.0", + "@effect/platform": "0.94.5", "@effect/platform-bun": "0.87.1", - "@effect/sql-sqlite-bun": "^0.50.0", + "@effect/rpc": "0.73.1", + "@effect/sql-sqlite-bun": "0.50.2", "@opencode-ai/sdk": "latest", "discord.js": "^14", - "effect": "^3", + "effect": "3.19.17", }, "devDependencies": { "@effect/language-service": "0.73.1", - "@effect/sql": "^0.49.0", + "@effect/sql": "0.49.0", "@types/bun": "latest", "@types/node": "^22", "typescript": "^5", @@ -907,19 +909,19 @@ "@effect/ai-anthropic": ["@effect/ai-anthropic@0.23.0", "", { "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4" }, "peerDependencies": { "@effect/ai": "^0.33.0", "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-ftkyQpTY4OCPTe9hlB53WCXseWm11lOf+zXDNCJG2nAqW5mZ+4kd/PPiqV7jd2zDukAtLc0I+R3EGs1fOLCG5A=="], - "@effect/cluster": ["@effect/cluster@0.56.3", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.4", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.16" } }, "sha512-SimiwC4Gzy71wnWOkAMdwxdo9CQhePGXrEz5OC5LQdIvoSzTCu9z82Rj8X5Vo1m1wlz6oNc3WmQLnwbbt/Eukg=="], + "@effect/cluster": ["@effect/cluster@0.56.4", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.5", "@effect/rpc": "^0.73.1", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.17" } }, "sha512-7Je5/JlbZOlsSxsbKjr97dJed2cNGWsb+TLNgMcr5mRDbcWlFOTUGvsrisEJV6waosYLIg+2omPdvnvRoYKdhA=="], "@effect/experimental": ["@effect/experimental@0.58.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA=="], "@effect/language-service": ["@effect/language-service@0.73.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-FbOKzXmP1QM6/YMvDFZGTZ0Gk0AjKqRSY48dpAN0zUdVzq5xV/Us/6vZ5FcdmG6GjgSWP2rpESy59OMvfPeylw=="], - "@effect/platform": ["@effect/platform@0.94.4", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.16" } }, "sha512-mK8pbskFAcBRA5Ooyt02kCBuWltZakyaDcM4ByTY0jXQMFC3NUveNK62JVH7XB+f3XZ8OoBBxUnlLben4plEKQ=="], + "@effect/platform": ["@effect/platform@0.94.5", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.17" } }, "sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A=="], "@effect/platform-bun": ["@effect/platform-bun@0.87.1", "", { "dependencies": { "@effect/platform-node-shared": "^0.57.1", "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-I88d0YqWbvLY2GGeIxK3r5k0l/MoUCCnxiHJG+X6gqaHu+pIs0djDtJ+ORhw/3qha9ojcVu6pyaBmnUjgzQHWQ=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="], - "@effect/rpc": ["@effect/rpc@0.73.0", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-iMPf6tTriz8sK0l5x4koFId8Hz5nFptHYg8WqyjHGIIVLTpZxuiSqhmXZG7FnAs5N2n6uCEws4wWGcIgXNUrFg=="], + "@effect/rpc": ["@effect/rpc@0.73.1", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.17" } }, "sha512-1Pn6GJl+gCdVl0O38zK8wU/I5UsG6RArDKd8Ndx8BhntZAPIBbuUFgG7KlAKTJjumBTO/YOEgKYdwma0rJq/Tw=="], "@effect/sql": ["@effect/sql@0.49.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA=="], @@ -2615,7 +2617,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@3.19.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-7+XC3vGrbAhCHd8LTFHvnZjRpZKZ8YHRZqJTkpNoxcJ2mCyNs2SwI+6VkV/ij8Y3YW7wfBN4EbU06/F5+m/wkQ=="], + "effect": ["effect@3.19.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-MChgn9z0y3KJ2DJ+VsZt1bdVDJ0bDIZt9zm4s3iPpRLzgcEGDOIP8pwADjAWifaP0S672nlRE5rZPBbyDcjn1g=="], "electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="], @@ -4639,7 +4641,7 @@ "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], - "@opencode/discord/@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.65", "", {}, "sha512-XlpgQJQ5WwO4tYgyyHoTT0NAB5/1StXonabVUAYVpW0JdtbwWFSdFEaLkWx6CU7MNW6ELP+SMC4n6wWO6zRW8Q=="], + "@opencode/discord/@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.1", "", {}, "sha512-K5e15mIXTyAykBw0GX+8O28IJHlPMw1jI/m3SDu+hgUHjmg2refqLPqyuqv8hE2nRcuGi8HajhpDJjkO7H2S0A=="], "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], diff --git a/packages/discord/.env.example b/packages/discord/.env.example index dbef3f0f78..b8f966393e 100644 --- a/packages/discord/.env.example +++ b/packages/discord/.env.example @@ -9,6 +9,7 @@ DISCORD_COMMAND_GUILD_ID= # Optional guild ID for instant slash command updat # Daytona DAYTONA_API_KEY= +DAYTONA_SNAPSHOT= # Optional; prebuilt snapshot name for faster sandbox creation # OpenCode (injected into sandboxes) OPENCODE_ZEN_API_KEY= diff --git a/packages/discord/README.md b/packages/discord/README.md index 5d88a00ded..4d9d69c265 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -97,6 +97,7 @@ This image does not require Docker Compose or special network wiring; only outbo | ---------------- | ---------------------------- | -------------------------------------------------- | | `DATABASE_PATH` | `discord.sqlite` | Path to the local SQLite file | | `GITHUB_TOKEN` | _(empty)_ | Injected into sandboxes for authenticated `gh` CLI | +| `DAYTONA_SNAPSHOT` | _(empty)_ | Prebuilt Daytona snapshot name for faster startup | | `OPENCODE_MODEL` | `opencode/claude-sonnet-4-5` | Model used inside OpenCode sessions | #### Optional — Bot Behavior @@ -127,10 +128,25 @@ This image does not require Docker Compose or special network wiring; only outbo | `bun run dev` | Watch mode | | `bun run start` | Production run | | `bun run db:init` | Initialize/migrate database | +| `bun run snapshot:create` | Build/activate a Daytona snapshot | | `bun run typecheck` | TypeScript checks | | `bun run build` | Bundle for deployment | | `bun run check` | Typecheck + build | +### Faster Sandbox Startup (Snapshot) + +Build and activate a reusable Daytona snapshot once: + +```bash +bun run snapshot:create opencode-discord-v1 +``` + +Then set this in `.env`: + +```bash +DAYTONA_SNAPSHOT=opencode-discord-v1 +``` + ### Discord Slash Commands - `/status` — show current sandbox session for the thread @@ -148,11 +164,13 @@ These map to the existing `!status` / `!reset` behavior. ``` Discord / CLI └─ Conversation service (Inbox → turn logic → Outbox) - ├─ ConversationLedger (dedup, at-least-once delivery, replay on restart) - └─ ThreadAgentPool.getOrCreate(threadId) - ├─ active? → health check → reuse - ├─ paused? → SandboxProvisioner.resume() → reattach session - └─ missing? → SandboxProvisioner.provision() → new sandbox + session + ├─ IngressDedup (message-id dedup in conversation path) + ├─ OffsetStore (durable Discord catch-up offsets) + └─ ThreadChatCluster.send(threadId) + └─ ThreadEntity (cluster actor per thread) + ├─ active? → health check → reuse + ├─ paused? → SandboxProvisioner.resume() → reattach session + └─ missing? → SandboxProvisioner.provision() → new sandbox + session ``` Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start. diff --git a/packages/discord/package.json b/packages/discord/package.json index 00cc3c9095..356f7b5834 100644 --- a/packages/discord/package.json +++ b/packages/discord/package.json @@ -8,9 +8,10 @@ "dev": "bun run --watch src/index.ts", "dev:setup": "bun run db:init", "start": "bun run src/index.ts", - "conversation:cli": "bun run src/conversation/control/cli.ts", - "conversation:controller": "bun run src/conversation/control/controller.ts", - "conversation:ctl": "bun run src/conversation/control/controller.ts", + "conversation:cli": "bun run src/control/cli.ts", + "conversation:controller": "bun run src/control/controller.ts", + "conversation:ctl": "bun run src/control/controller.ts", + "snapshot:create": "bun run src/sandbox/daytona/snapshot.ts", "build": "bun build src/index.ts --target=bun --outdir=dist", "typecheck": "tsc --noEmit", "check": "bun run typecheck && bun run build", @@ -18,19 +19,21 @@ }, "dependencies": { "@daytonaio/sdk": "latest", - "@effect/ai": "^0.33.2", - "@effect/ai-anthropic": "^0.23.0", - "@effect/experimental": "^0.58.0", - "@effect/sql-sqlite-bun": "^0.50.0", - "@effect/platform": "latest", + "@effect/ai": "0.33.2", + "@effect/ai-anthropic": "0.23.0", + "@effect/cluster": "0.56.4", + "@effect/experimental": "0.58.0", + "@effect/platform": "0.94.5", "@effect/platform-bun": "0.87.1", + "@effect/rpc": "0.73.1", + "@effect/sql-sqlite-bun": "0.50.2", "@opencode-ai/sdk": "latest", "discord.js": "^14", - "effect": "^3" + "effect": "3.19.17" }, "devDependencies": { "@effect/language-service": "0.73.1", - "@effect/sql": "^0.49.0", + "@effect/sql": "0.49.0", "@types/bun": "latest", "@types/node": "^22", "typescript": "^5" diff --git a/packages/discord/src/agent-prompt.md b/packages/discord/src/agent-prompt.md index 0be5561822..3c44f4b88d 100644 --- a/packages/discord/src/agent-prompt.md +++ b/packages/discord/src/agent-prompt.md @@ -26,6 +26,15 @@ your job is to help people solve real code and operations problems quickly. - use github cli (`gh`) for issues/prs when asked, or when explicitly instructed to file findings. - use web lookup when external, time-sensitive, or non-repo facts are needed. +## github identity and capabilities + +- your github account name is `opendude`. +- your github identity is the account authenticated in the sandbox via `GH_TOKEN` / `GITHUB_TOKEN`. +- if the user asks who you are on github, check with `gh auth status` or `gh api user`. +- you can create branches, push commits, open pull requests, open issues, and post issue/pr comments when repo permissions allow. +- default to creating pull requests against `dev` unless the user specifies another base branch. +- do not merge pull requests unless the user explicitly asks and permissions allow it. + ## github issue workflow when creating an issue, include: diff --git a/packages/discord/src/app/layers.ts b/packages/discord/src/app/layers.ts new file mode 100644 index 0000000000..5a3e60d250 --- /dev/null +++ b/packages/discord/src/app/layers.ts @@ -0,0 +1,79 @@ +import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic" +import { SingleRunner, TestRunner } from "@effect/cluster" +import { FetchHttpClient } from "@effect/platform" +import { BunContext } from "@effect/platform-bun" +import { Effect, Layer, LogLevel, Logger } from "effect" +import { Conversation } from "../conversation/conversation" +import { IngressDedup } from "../conversation/dedup" +import { History } from "../conversation/history" +import { OffsetStore } from "../conversation/offsets" +import { ThreadChatClusterLive, ThreadControlClusterLive, ThreadEntityLive } from "../conversation/thread/cluster" +import { AppConfig } from "../config" +import { SqliteDb } from "../db/client" +import { DiscordConversationServicesLive } from "../discord/adapter" +import { DiscordClient } from "../discord/client" +import { TurnRouter } from "../discord/turn-routing" +import { LoggerLive } from "../observability/logger" +import { DaytonaService } from "../sandbox/daytona/service" +import { OpenCodeClient } from "../sandbox/opencode/client" +import { SandboxProvisioner } from "../sandbox/provisioner" +import { SessionStore } from "../session/store" + +export const AnthropicLayer = Layer.unwrapEffect( + Effect.gen(function* () { + const config = yield* AppConfig + return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe( + Layer.provide(AnthropicClient.layer({ + apiKey: config.openCodeZenApiKey, + apiUrl: "https://opencode.ai/zen", + })), + Layer.provide(FetchHttpClient.layer), + ) + }), +) + +const Base = Layer.mergeAll(AppConfig.layer, FetchHttpClient.layer, BunContext.layer) + +const AppBase = Layer.mergeAll(Base, LoggerLive) +const AppWithSqlite = Layer.provideMerge(SqliteDb.layer, AppBase) +const AppWithAnthropic = Layer.provideMerge(AnthropicLayer, AppWithSqlite) +const AppWithDaytona = Layer.provideMerge(DaytonaService.layer, AppWithAnthropic) +const AppWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, AppWithDaytona) +const AppWithRouting = Layer.provideMerge(TurnRouter.layer, AppWithOpenCode) +const AppWithSessions = Layer.provideMerge(SessionStore.layer, AppWithRouting) +const AppWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, AppWithSessions) +const AppWithOffsets = Layer.provideMerge(OffsetStore.layer, AppWithProvisioner) +const AppWithDedup = Layer.provideMerge(IngressDedup.layer, AppWithOffsets) +const AppWithDiscordClient = Layer.provideMerge(DiscordClient.layer, AppWithDedup) +const AppWithDiscordAdapters = Layer.provideMerge(DiscordConversationServicesLive, AppWithDiscordClient) +const AppWithRunner = Layer.provideMerge(SingleRunner.layer({ runnerStorage: "memory" }), AppWithDiscordAdapters) +const AppWithThreadEntity = Layer.provideMerge(ThreadEntityLive, AppWithRunner) +const AppWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, AppWithThreadEntity) +export const AppConversationLayer = Layer.provideMerge(Conversation.layer, AppWithThreadChat) + +const ControlBase = Layer.mergeAll(Base, Logger.minimumLogLevel(LogLevel.None)) +const ControlWithSqlite = Layer.provideMerge(SqliteDb.layer, ControlBase) +const ControlWithDaytona = Layer.provideMerge(DaytonaService.layer, ControlWithSqlite) +const ControlWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, ControlWithDaytona) +const ControlWithSessions = Layer.provideMerge(SessionStore.layer, ControlWithOpenCode) +const ControlWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, ControlWithSessions) +const ControlWithRunner = Layer.provideMerge(TestRunner.layer, ControlWithProvisioner) +const ControlWithHistory = Layer.provideMerge(History.passthrough, ControlWithRunner) +const ControlWithThreadEntity = Layer.provideMerge(ThreadEntityLive, ControlWithHistory) +const ControlWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, ControlWithThreadEntity) +export const ControlThreadLayer = Layer.provideMerge(ThreadControlClusterLive, ControlWithThreadChat) + +const CliBase = Layer.mergeAll(Base, Logger.minimumLogLevel(LogLevel.Warning)) +const CliWithSqlite = Layer.provideMerge(SqliteDb.layer, CliBase) +const CliWithAnthropic = Layer.provideMerge(AnthropicLayer, CliWithSqlite) +const CliWithDaytona = Layer.provideMerge(DaytonaService.layer, CliWithAnthropic) +const CliWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, CliWithDaytona) +const CliWithRouting = Layer.provideMerge(TurnRouter.layer, CliWithOpenCode) +const CliWithSessions = Layer.provideMerge(SessionStore.layer, CliWithRouting) +const CliWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, CliWithSessions) +const CliWithRunner = Layer.provideMerge(TestRunner.layer, CliWithProvisioner) +const CliWithHistory = Layer.provideMerge(History.passthrough, CliWithRunner) +const CliWithThreadEntity = Layer.provideMerge(ThreadEntityLive, CliWithHistory) +const CliWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, CliWithThreadEntity) +const CliWithThreadControl = Layer.provideMerge(ThreadControlClusterLive, CliWithThreadChat) +export const CliConversationStaticLayer = Layer.provideMerge(IngressDedup.noop, CliWithThreadControl) diff --git a/packages/discord/src/config.ts b/packages/discord/src/config.ts index 5f454914b4..5bf2c4da12 100644 --- a/packages/discord/src/config.ts +++ b/packages/discord/src/config.ts @@ -58,6 +58,7 @@ export declare namespace AppConfig { readonly discordCommandGuildId: string readonly databasePath: string readonly daytonaApiKey: Redacted.Redacted + readonly daytonaSnapshot: string readonly openCodeZenApiKey: Redacted.Redacted readonly githubToken: string readonly logLevel: LogLevel @@ -102,6 +103,9 @@ export class AppConfig extends Context.Tag("@discord/AppConfig")/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1' let scope = base() let pending = 0 let last: ThreadId | null = null @@ -106,55 +63,55 @@ const run = Effect.gen(function* () { stdout.write(`${prompt(scope)}${rl.line}`) }) - const noteThread = (thread_id: ThreadId): Effect.Effect => + const noteThread = (threadId: ThreadId): Effect.Effect => Effect.sync(() => { - seen.add(thread_id) - last = thread_id + seen.add(threadId) + last = threadId }) - const pick = (thread_id: ThreadId | null): ThreadId | null => { - if (thread_id) return thread_id - if (scope.kind === "thread") return scope.thread_id + const pick = (threadId: ThreadId | null): ThreadId | null => { + if (threadId) return threadId + if (scope.kind === "thread") return scope.threadId return last } const list = () => Array.from(seen) const byIndex = (index: number) => list().at(index - 1) ?? null - const fromRef = (thread_id: ThreadId | null) => { - if (!thread_id) return null - const raw = `${thread_id}`.trim() - if (!/^\d+$/.test(raw)) return thread_id + const fromRef = (threadId: ThreadId | null) => { + if (!threadId) return null + const raw = `${threadId}`.trim() + if (!/^\d+$/.test(raw)) return threadId const index = Number(raw) if (!Number.isInteger(index) || index <= 0) return null return byIndex(index) } - const tracked = (thread_id: ThreadId) => - pool.getTrackedSession(thread_id).pipe( + const tracked = (threadId: ThreadId) => + threadChat.status(threadId).pipe( Effect.map((row) => Option.isSome(row) ? row.value : null), Effect.catchAll(() => Effect.succeed(null)), ) - const sessionText = (thread_id: ThreadId, session: { + const sessionText = (threadId: ThreadId, session: { status: string sandboxId: string sessionId: string resumeFailCount: number lastError: string | null }) => - `${colors.dim}${thread_id}${colors.reset} status=${session.status} sandbox=${session.sandboxId} session=${session.sessionId} resume_failures=${session.resumeFailCount}${session.lastError ? ` error=${session.lastError.slice(0, 120)}` : ""}` + `${colors.dim}${threadId}${colors.reset} status=${session.status} sandbox=${session.sandboxId} session=${session.sessionId} resume_failures=${session.resumeFailCount}${session.lastError ? ` error=${session.lastError.slice(0, 120)}` : ""}` const render = (action: Action) => { if (action.kind === "typing") { - return stamp("typing", colors.yellow, `${colors.dim}[${action.thread_id}]${colors.reset}`) + return stamp("typing", colors.yellow, `${colors.dim}[${action.threadId}]${colors.reset}`) } - return stamp("assistant", colors.cyan, `${colors.dim}[${action.thread_id}]${colors.reset} ${action.text}`) + return stamp("assistant", colors.cyan, `${colors.dim}[${action.threadId}]${colors.reset} ${action.text}`) } yield* draw( stamp( "ready", colors.yellow, - `${colors.dim}Type messages. /thread [id|n], /pick [n], /channel, /threads, /status, /logs, /restart, /pause, /destroy, /resume, /active, /help, /exit${colors.reset}`, + `${colors.dim}Type messages. /thread [id|n], /pick [n], /channel, /threads, /status, /logs, /pause, /recreate, /resume, /active, /help, /exit${colors.reset}`, ), false, ) @@ -164,8 +121,8 @@ const run = Effect.gen(function* () { tui.actions, (action) => Effect.gen(function* () { - const known = seen.has(action.thread_id) - yield* noteThread(action.thread_id) + const known = seen.has(action.threadId) + yield* noteThread(action.threadId) const next = autoThread(scope, action, known) const switched = scope.kind === "channel" && next.kind === "thread" scope = next @@ -188,7 +145,7 @@ const run = Effect.gen(function* () { if (scope.kind === "channel") { yield* tui.send(text) } else { - yield* tui.sendTo(scope.thread_id, text) + yield* tui.sendTo(scope.threadId, text) } pending += 1 yield* draw(stamp("queued", colors.green, `${colors.dim}[${target}]${colors.reset} ${text}`), false) @@ -210,7 +167,7 @@ const run = Effect.gen(function* () { if (cmd.kind === "help") { yield* info( - `${colors.dim}/thread [id|n], /pick [n], /channel, /threads, /status [thread], /logs [lines] [thread], /restart [thread], /pause [thread], /destroy [thread], /resume [thread], /active, /exit${colors.reset}`, + `${colors.dim}/thread [id|n], /pick [n], /channel, /threads, /status [thread], /logs [lines] [thread], /pause [thread], /recreate [thread], /resume [thread], /active, /exit${colors.reset}`, ) return true } @@ -234,18 +191,18 @@ const run = Effect.gen(function* () { yield* info(`${colors.dim}pick one with /pick ${colors.reset}`) return true } - const thread_id = byIndex(cmd.index) - if (!thread_id) { + const threadId = byIndex(cmd.index) + if (!threadId) { yield* info(`${colors.dim}invalid thread index ${cmd.index}${colors.reset}`) return true } - scope = threadFrom(scope, thread_id) + scope = threadFrom(scope, threadId) yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`) return true } if (cmd.kind === "active") { - yield* sessions.listActive().pipe( + yield* threadControl.active.pipe( Effect.matchEffect({ onFailure: (error) => info(`${colors.red}active query failed${colors.reset} ${String(error)}`), @@ -265,14 +222,14 @@ const run = Effect.gen(function* () { } if (cmd.kind === "thread") { - const selected = fromRef(cmd.thread_id) + const selected = fromRef(cmd.threadId) if (selected) { scope = threadFrom(scope, selected) yield* noteThread(selected) yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`) return true } - if (cmd.thread_id) { + if (cmd.threadId) { yield* info(`${colors.dim}invalid thread id/index${colors.reset}`) return true } @@ -286,149 +243,107 @@ const run = Effect.gen(function* () { } if (cmd.kind === "status") { - const thread_id = pick(cmd.thread_id) - if (!thread_id) { + const threadId = pick(cmd.threadId) + if (!threadId) { yield* info(`${colors.dim}no thread selected. use /thread ${colors.reset}`) return true } - yield* noteThread(thread_id) - const session = yield* tracked(thread_id) + yield* noteThread(threadId) + const session = yield* tracked(threadId) if (!session) { - yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`) + yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`) return true } - yield* info(sessionText(thread_id, session)) + yield* info(sessionText(threadId, session)) return true } if (cmd.kind === "logs") { - const thread_id = pick(cmd.thread_id) - if (!thread_id) { + const threadId = pick(cmd.threadId) + if (!threadId) { yield* info(`${colors.dim}no thread selected. use /thread ${colors.reset}`) return true } - yield* noteThread(thread_id) - const session = yield* tracked(thread_id) - if (!session) { - yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`) + yield* noteThread(threadId) + const row = yield* threadControl.logs({ threadId, lines: cmd.lines }).pipe( + Effect.catchAll(() => Effect.succeed(Option.none())), + ) + if (Option.isNone(row)) { + yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`) return true } - yield* daytona.exec( - session.sandboxId, - "read-opencode-log", - `cat /tmp/opencode.log 2>/dev/null | tail -${cmd.lines}`, - ).pipe( - Effect.matchEffect({ - onFailure: (error) => - info(`${colors.red}log read failed${colors.reset} ${String(error)}`), - onSuccess: (result) => - block( - stamp("logs", colors.blue, `${colors.dim}[${thread_id}]${colors.reset}`), - result.output.trim() || "(empty log)", - ), - }), + yield* block( + stamp("logs", colors.blue, `${colors.dim}[${threadId}]${colors.reset}`), + row.value.output.trim() || "(empty log)", ) return true } if (cmd.kind === "pause") { - const thread_id = pick(cmd.thread_id) - if (!thread_id) { + const threadId = pick(cmd.threadId) + if (!threadId) { yield* info(`${colors.dim}no thread selected. use /thread ${colors.reset}`) return true } - yield* noteThread(thread_id) - yield* pool.pauseSession(thread_id, "manual-cli").pipe( - Effect.matchEffect({ - onFailure: (error) => - info(`${colors.red}pause failed${colors.reset} ${String(error)}`), - onSuccess: () => - info(`${colors.dim}paused ${thread_id}${colors.reset}`), - }), + yield* noteThread(threadId) + const row = yield* threadControl.pause({ threadId, reason: "manual-cli" }).pipe( + Effect.catchAll(() => Effect.succeed(Option.none())), ) + if (Option.isNone(row)) { + yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`) + return true + } + yield* info(`${colors.dim}paused ${threadId}${colors.reset}`) return true } - if (cmd.kind === "destroy") { - const thread_id = pick(cmd.thread_id) - if (!thread_id) { + if (cmd.kind === "recreate") { + const threadId = pick(cmd.threadId) + if (!threadId) { yield* info(`${colors.dim}no thread selected. use /thread ${colors.reset}`) return true } - yield* noteThread(thread_id) - yield* pool.destroySession(thread_id).pipe( + yield* noteThread(threadId) + yield* threadChat.recreate(threadId).pipe( Effect.matchEffect({ onFailure: (error) => - info(`${colors.red}destroy failed${colors.reset} ${String(error)}`), + info(`${colors.red}recreate failed${colors.reset} ${String(error)}`), onSuccess: () => - info(`${colors.dim}destroyed ${thread_id}${colors.reset}`), + info(`${colors.dim}recreated ${threadId}${colors.reset}`), }), ) return true } if (cmd.kind === "resume") { - const thread_id = pick(cmd.thread_id) - if (!thread_id) { + const threadId = pick(cmd.threadId) + if (!threadId) { yield* info(`${colors.dim}no thread selected. use /thread ${colors.reset}`) return true } - yield* noteThread(thread_id) - const session = yield* tracked(thread_id) - if (!session) { - yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`) + const session = yield* tracked(threadId) + if (session === null) { + yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`) return true } - yield* pool.getOrCreate(thread_id, session.channelId, session.guildId).pipe( + yield* noteThread(threadId) + yield* threadControl.resume({ + threadId, + channelId: session.channelId, + guildId: session.guildId, + }).pipe( Effect.matchEffect({ onFailure: (error) => info(`${colors.red}resume failed${colors.reset} ${String(error)}`), - onSuccess: (agent) => - agent.current().pipe( - Effect.flatMap((current) => - info( - `${colors.dim}resumed ${thread_id} sandbox=${current.sandboxId} session=${current.sessionId}${colors.reset}`, - ), - ), - Effect.catchAll((error) => - info(`${colors.red}resume failed${colors.reset} ${String(error)}`), - ), + onSuccess: (current) => + info( + `${colors.dim}resumed ${threadId} sandbox=${current.sandboxId} session=${current.sessionId}${colors.reset}`, ), }), ) return true } - if (cmd.kind === "restart") { - const thread_id = pick(cmd.thread_id) - if (!thread_id) { - yield* info(`${colors.dim}no thread selected. use /thread ${colors.reset}`) - return true - } - yield* noteThread(thread_id) - const session = yield* tracked(thread_id) - if (!session) { - yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`) - return true - } - const restarted = yield* daytona.exec(session.sandboxId, "restart-opencode-serve", restart).pipe( - Effect.as(true), - Effect.catchAll((error) => - info(`${colors.red}restart failed${colors.reset} ${String(error)}`).pipe(Effect.as(false)), - ), - ) - if (!restarted) return true - const healthy = yield* oc.waitForHealthy(PreviewAccess.from(session), config.activeHealthCheckTimeoutMs).pipe( - Effect.catchAll(() => Effect.succeed(false)), - ) - if (!healthy) { - yield* info(`${colors.red}restart ran, but health check failed${colors.reset}`) - return true - } - yield* info(`${colors.dim}restart complete and healthy${colors.reset}`) - return true - } - yield* info(`${colors.dim}unknown command: /${cmd.name}${colors.reset}`) return true }) diff --git a/packages/discord/src/conversation/control/controller.ts b/packages/discord/src/control/controller.ts similarity index 56% rename from packages/discord/src/conversation/control/controller.ts rename to packages/discord/src/control/controller.ts index e195a770e8..6d753c4fb4 100644 --- a/packages/discord/src/conversation/control/controller.ts +++ b/packages/discord/src/control/controller.ts @@ -1,30 +1,8 @@ -import { FetchHttpClient } from "@effect/platform" -import { BunContext, BunRuntime } from "@effect/platform-bun" -import { Duration, Effect, Exit, Fiber, Layer, LogLevel, Logger, Option, Ref, Schema } from "effect" -import { AppConfig } from "../../config" -import { SqliteDb } from "../../db/client" -import { DaytonaService } from "../../sandbox/daytona" -import { OpenCodeClient } from "../../sandbox/opencode-client" -import { ThreadAgentPool } from "../../sandbox/pool" -import { SandboxProvisioner } from "../../sandbox/provisioner" -import { SessionStore } from "../../sessions/store" -import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../../types" - -const BaseLayer = Layer.mergeAll( - AppConfig.layer, - FetchHttpClient.layer, - BunContext.layer, - Logger.minimumLogLevel(LogLevel.None), -) -const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer) -const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithSqlite) -const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona) -const WithSessions = Layer.provideMerge(SessionStore.layer, WithOpenCode) -const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions) -const CoreLayer = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner) - -const restart = - 'pkill -f \'opencode serve --port 4096\' >/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1' +import { BunRuntime } from "@effect/platform-bun" +import { Duration, Effect, Exit, Fiber, Option, Ref, Schema } from "effect" +import { ControlThreadLayer } from "../app/layers" +import { ThreadChatCluster, ThreadControlCluster } from "../conversation/thread/cluster" +import { ChannelId, GuildId, SandboxId, SessionInfo, ThreadId } from "../types" type Opt = Record @@ -111,6 +89,7 @@ const flag = (opts: Opt, key: string) => { } let ctlSeq = 0 +let msgSeq = 0 const pick = (opts: Opt, active: ReadonlyArray<{ threadId: ThreadId }>) => { const raw = value(opts, "thread") if (raw) return Effect.succeed(ThreadId.make(raw)) @@ -119,6 +98,10 @@ const pick = (opts: Opt, active: ReadonlyArray<{ threadId: ThreadId }>) => { ctlSeq += 1 return Effect.succeed(ThreadId.make(`ctl-${ctlSeq}`)) } +const messageId = () => { + msgSeq += 1 + return `ctl-msg-${Date.now()}-${msgSeq}` +} const print = (ok: boolean, command: string, payload: Record) => Effect.sync(() => { @@ -145,49 +128,28 @@ const event = (command: string, name: string, payload: Record) const run = Effect.gen(function* () { const ctl = parse(process.argv) - const config = yield* AppConfig - const pool = yield* ThreadAgentPool - const sessions = yield* SessionStore - const daytona = yield* DaytonaService - const oc = yield* OpenCodeClient - const active = yield* sessions.listActive() - const tracked = (thread_id: ThreadId) => + const threadChat = yield* ThreadChatCluster + const threadControl = yield* ThreadControlCluster + const active = yield* threadControl.active + const status = (threadId: ThreadId) => + threadChat.status(threadId).pipe( + Effect.catchAll((cause) => internal(cause)), + ) + const context = (threadId: ThreadId, opts: Opt) => Effect.gen(function* () { - const row = yield* pool.getTrackedSession(thread_id) - if (Option.isSome(row)) return row.value - return yield* usage(`no tracked session for thread ${thread_id}`) - }) - const resolve = (thread_id: ThreadId, opts: Opt): Effect.Effect => - Effect.gen(function* () { - const row = yield* pool.getTrackedSession(thread_id).pipe( - Effect.catchAll((cause) => - internal(cause), - ), - ) + const row = yield* status(threadId) if (Option.isSome(row)) { - const agent = yield* pool.getOrCreate(thread_id, row.value.channelId, row.value.guildId).pipe( - Effect.catchAll((cause) => - internal(cause), - ), - ) - return yield* agent.current().pipe( - Effect.catchAll((cause) => - internal(cause), - ), - ) + return { + channelId: row.value.channelId, + guildId: row.value.guildId, + } } const channel = value(opts, "channel") ?? "ctl" const guild = value(opts, "guild") ?? "local" - const agent = yield* pool.getOrCreate(thread_id, ChannelId.make(channel), GuildId.make(guild)).pipe( - Effect.catchAll((cause) => - internal(cause), - ), - ) - return yield* agent.current().pipe( - Effect.catchAll((cause) => - internal(cause), - ), - ) + return { + channelId: ChannelId.make(channel), + guildId: GuildId.make(guild), + } }) if (ctl.cmd === "help") { @@ -197,9 +159,8 @@ const run = Effect.gen(function* () { "conversation:ctl status --thread ", "conversation:ctl logs --thread [--lines 120]", "conversation:ctl pause --thread ", - "conversation:ctl destroy --thread ", + "conversation:ctl recreate --thread ", "conversation:ctl resume --thread [--channel --guild ]", - "conversation:ctl restart --thread ", "conversation:ctl send --thread --text [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]", ], }) @@ -222,62 +183,69 @@ const run = Effect.gen(function* () { } if (ctl.cmd === "status") { - const thread_id = yield* pick(ctl.opts, active) - return yield* pool.getTrackedSession(thread_id).pipe( - Effect.flatMap((row) => - print(true, ctl.cmd, { - threadId: thread_id, - tracked: Option.isSome(row), - session: Option.isSome(row) - ? { - threadId: row.value.threadId, - channelId: row.value.channelId, - guildId: row.value.guildId, - sandboxId: row.value.sandboxId, - sessionId: row.value.sessionId, - status: row.value.status, - resumeFailCount: row.value.resumeFailCount, - lastError: row.value.lastError, - } - : null, - })), - ) + const threadId = yield* pick(ctl.opts, active) + const row = yield* status(threadId) + return yield* print(true, ctl.cmd, { + threadId: threadId, + tracked: Option.isSome(row), + session: Option.isSome(row) + ? { + threadId: row.value.threadId, + channelId: row.value.channelId, + guildId: row.value.guildId, + sandboxId: row.value.sandboxId, + sessionId: row.value.sessionId, + status: row.value.status, + resumeFailCount: row.value.resumeFailCount, + lastError: row.value.lastError, + } + : null, + }) } if (ctl.cmd === "logs") { - const thread_id = yield* pick(ctl.opts, active) + const threadId = yield* pick(ctl.opts, active) const lines = number(ctl.opts, "lines", 120) - const row = yield* tracked(thread_id) - const out = yield* daytona.exec( - row.sandboxId, - "read-opencode-log", - `cat /tmp/opencode.log 2>/dev/null | tail -${lines}`, + const row = yield* threadControl.logs({ threadId, lines }).pipe( + Effect.catchAll((cause) => internal(cause)), ) + if (Option.isNone(row)) return yield* usage(`no tracked session for thread ${threadId}`) return yield* print(true, ctl.cmd, { - threadId: thread_id, - sandboxId: row.sandboxId, + threadId: threadId, + sandboxId: row.value.sandboxId, lines, - output: out.output, + output: row.value.output, }) } if (ctl.cmd === "pause") { - const thread_id = yield* pick(ctl.opts, active) - yield* pool.pauseSession(thread_id, "manual-ctl") - return yield* print(true, ctl.cmd, { threadId: thread_id }) + const threadId = yield* pick(ctl.opts, active) + const row = yield* threadControl.pause({ threadId, reason: "manual-ctl" }).pipe( + Effect.catchAll((cause) => internal(cause)), + ) + if (Option.isNone(row)) return yield* usage(`no tracked session for thread ${threadId}`) + return yield* print(true, ctl.cmd, { threadId: threadId }) } - if (ctl.cmd === "destroy") { - const thread_id = yield* pick(ctl.opts, active) - yield* pool.destroySession(thread_id) - return yield* print(true, ctl.cmd, { threadId: thread_id }) + if (ctl.cmd === "recreate" || ctl.cmd === "destroy") { + const threadId = yield* pick(ctl.opts, active) + yield* threadChat.recreate(threadId) + return yield* print(true, ctl.cmd, { threadId: threadId }) } if (ctl.cmd === "resume") { - const thread_id = yield* pick(ctl.opts, active) - const row = yield* resolve(thread_id, ctl.opts) + const threadId = yield* pick(ctl.opts, active) + const channel = value(ctl.opts, "channel") + const guild = value(ctl.opts, "guild") + const row = yield* threadControl.resume({ + threadId, + channelId: channel ? ChannelId.make(channel) : null, + guildId: guild ? GuildId.make(guild) : null, + }).pipe( + Effect.catchAll((cause) => internal(cause)), + ) return yield* print(true, ctl.cmd, { - threadId: thread_id, + threadId: threadId, session: { sandboxId: row.sandboxId, sessionId: row.sessionId, @@ -286,22 +254,8 @@ const run = Effect.gen(function* () { }) } - if (ctl.cmd === "restart") { - const thread_id = yield* pick(ctl.opts, active) - const row = yield* resolve(thread_id, ctl.opts) - yield* daytona.exec(row.sandboxId, "restart-opencode-serve", restart) - const healthy = yield* oc.waitForHealthy(PreviewAccess.from(row), config.activeHealthCheckTimeoutMs).pipe( - Effect.catchAll(() => Effect.succeed(false)), - ) - return yield* print(true, ctl.cmd, { - threadId: thread_id, - sandboxId: row.sandboxId, - healthy, - }) - } - if (ctl.cmd === "send") { - const thread_id = yield* pick(ctl.opts, active) + const threadId = yield* pick(ctl.opts, active) const message = value(ctl.opts, "text") ?? ctl.args.join(" ").trim() if (!message) { return yield* usage("missing message text (pass --text \"...\")") @@ -312,19 +266,23 @@ const run = Effect.gen(function* () { const follow = flag(ctl.opts, "follow") || wait > 0 if (!follow) { - const row = yield* resolve(thread_id, ctl.opts) - const agent = yield* pool.getOrCreate(thread_id, row.channelId, row.guildId) - const reply = yield* agent.send(message) - const current = yield* agent.current() + const input = yield* context(threadId, ctl.opts) + const reply = yield* threadChat.send({ + threadId, + channelId: input.channelId, + guildId: input.guildId, + messageId: messageId(), + text: message, + }) return yield* print(true, ctl.cmd, { - threadId: thread_id, - sandboxId: current.sandboxId, - sessionId: current.sessionId, - reply, + threadId: threadId, + sandboxId: reply.session.sandboxId, + sessionId: reply.session.sessionId, + reply: reply.text, }) } - const known = yield* pool.getTrackedSession(thread_id).pipe( + const known = yield* threadChat.status(threadId).pipe( Effect.catchAll(() => Effect.succeed(Option.none())), ) const sandbox = yield* Ref.make(Option.isSome(known) ? known.value.sandboxId : null) @@ -333,17 +291,21 @@ const run = Effect.gen(function* () { const fiber = yield* Effect.fork( Effect.gen(function* () { - const row = yield* resolve(thread_id, ctl.opts) - yield* Ref.set(sandbox, row.sandboxId) - const agent = yield* pool.getOrCreate(thread_id, row.channelId, row.guildId) - const reply = yield* agent.send(message) - const current = yield* agent.current() - return { row: current, reply } + const input = yield* context(threadId, ctl.opts) + const reply = yield* threadChat.send({ + threadId, + channelId: input.channelId, + guildId: input.guildId, + messageId: messageId(), + text: message, + }) + yield* Ref.set(sandbox, reply.session.sandboxId) + return { row: reply.session, reply: reply.text } }), ) yield* event(ctl.cmd, "started", { - threadId: thread_id, + threadId: threadId, waitMs: wait, logsEveryMs: every, lines, @@ -367,7 +329,7 @@ const run = Effect.gen(function* () { const sandboxId = yield* Ref.get(sandbox) if (!sandboxId) { yield* event(ctl.cmd, "progress", { - threadId: thread_id, + threadId: threadId, elapsedMs: elapsed, stage: "resolving-session", }) @@ -375,29 +337,23 @@ const run = Effect.gen(function* () { return yield* loop() } - const output = yield* daytona.exec( - sandboxId, - "read-opencode-log", - `cat /tmp/opencode.log 2>/dev/null | tail -${lines}`, - ).pipe( - Effect.map((row) => row.output), - Effect.catchAll((cause) => - Effect.succeed(`(log read failed: ${text(cause)})`), - ), + const output = yield* threadControl.logs({ threadId, lines }).pipe( + Effect.map((row) => Option.isSome(row) ? row.value.output : "(no tracked session)"), + Effect.catchAll((cause) => Effect.succeed(`(log read failed: ${text(cause)})`)), ) const previous = yield* Ref.get(last) if (output !== previous) { yield* Ref.set(last, output) yield* event(ctl.cmd, "progress", { - threadId: thread_id, + threadId: threadId, elapsedMs: elapsed, sandboxId, logs: output, }) } else { yield* event(ctl.cmd, "progress", { - threadId: thread_id, + threadId: threadId, elapsedMs: elapsed, sandboxId, logs: "(no change)", @@ -410,7 +366,7 @@ const run = Effect.gen(function* () { const result = yield* loop() return yield* print(true, ctl.cmd, { - threadId: thread_id, + threadId: threadId, sandboxId: result.row.sandboxId, sessionId: result.row.sessionId, reply: result.reply, @@ -430,7 +386,7 @@ const run = Effect.gen(function* () { ) run.pipe( - Effect.provide(CoreLayer), + Effect.provide(ControlThreadLayer), Effect.scoped, BunRuntime.runMain, ) diff --git a/packages/discord/src/conversation/implementations/local/index.test.ts b/packages/discord/src/control/local-adapter.test.ts similarity index 65% rename from packages/discord/src/conversation/implementations/local/index.test.ts rename to packages/discord/src/control/local-adapter.test.ts index d3aff5a2e1..cb63798e24 100644 --- a/packages/discord/src/conversation/implementations/local/index.test.ts +++ b/packages/discord/src/control/local-adapter.test.ts @@ -1,12 +1,12 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Option } from "effect" -import { TurnRouter, TurnRoutingDecision } from "../../../discord/turn-routing" -import { ThreadAgentPool, type ThreadAgent } from "../../../sandbox/pool" -import { effectTest, testConfigLayer } from "../../../test/effect" -import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../../../types" -import { Conversation } from "../../services/conversation" -import { ConversationLedger } from "../../services/ledger" -import { makeTui } from "./index" +import { ThreadChatCluster, ThreadChatError } from "../conversation/thread/cluster" +import { TurnRouter, TurnRoutingDecision } from "../discord/turn-routing" +import { effectTest, testConfigLayer } from "../test/effect" +import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types" +import { Conversation } from "../conversation/conversation" +import { IngressDedup } from "../conversation/dedup" +import { makeTui } from "./local-adapter" const makeSession = (id: string) => SessionInfo.make({ @@ -31,42 +31,34 @@ const routerLayer = Layer.succeed( }), ) -const makePoolLayer = (opts: { - getOrCreate?: ThreadAgentPool.Service["getOrCreate"] +const makeThreadChatLayer = (opts: { send?: (prompt: string) => string seen?: Array gate?: Deferred.Deferred }) => { const session = makeSession("s1") const seen = opts.seen ?? [] - const defaultGetOrCreate: ThreadAgentPool.Service["getOrCreate"] = () => - Effect.gen(function* () { - if (opts.gate) yield* Deferred.await(opts.gate) - return { - threadId: session.threadId, - session, - current: () => Effect.succeed(session), - send: (prompt: string) => - Effect.sync(() => { - seen.push(prompt) - return opts.send ? opts.send(prompt) : `local:${prompt}` - }), - pause: () => Effect.void, - destroy: () => Effect.void, - } satisfies ThreadAgent - }) - - return Layer.succeed( - ThreadAgentPool, - ThreadAgentPool.of({ - getOrCreate: opts.getOrCreate ?? defaultGetOrCreate, - hasTrackedThread: () => Effect.succeed(true), - getTrackedSession: () => Effect.succeed(Option.none()), - getActiveSessionCount: () => Effect.succeed(0), - pauseSession: () => Effect.void, - destroySession: () => Effect.void, - }), - ) + return Layer.succeed(ThreadChatCluster, ThreadChatCluster.of({ + send: (input) => + Effect.gen(function* () { + if (opts.gate) yield* Deferred.await(opts.gate) + seen.push(input.text) + return { + text: opts.send ? opts.send(input.text) : `local:${input.text}`, + session, + changedSession: false, + } + }).pipe( + Effect.mapError((cause) => + ThreadChatError.make({ + threadId: input.threadId, + cause, + retriable: false, + })), + ), + status: () => Effect.succeed(Option.some(session)), + recreate: () => Effect.void, + })) } describe("makeTui", () => { @@ -74,13 +66,13 @@ describe("makeTui", () => { Effect.gen(function* () { const seen: Array = [] const tui = yield* makeTui - const poolLayer = makePoolLayer({ seen }) + const threadChatLayer = makeThreadChatLayer({ seen }) const live = Conversation.layer.pipe( Layer.provideMerge(tui.layer), - Layer.provideMerge(ConversationLedger.noop), + Layer.provideMerge(IngressDedup.noop), Layer.provideMerge(routerLayer), - Layer.provideMerge(poolLayer), + Layer.provideMerge(threadChatLayer), Layer.provideMerge(testConfigLayer), ) @@ -96,7 +88,7 @@ describe("makeTui", () => { expect(seen).toEqual(["hello local"]) expect(first.kind).toBe("typing") expect(second.kind).toBe("send") - expect(/^thread-[a-z]+-[a-z]+-\d+$/.test(String(second.thread_id))).toBe(true) + expect(/^thread-[a-z]+-[a-z]+-\d+$/.test(String(second.threadId))).toBe(true) if (second.kind === "send") expect(second.text).toBe("local:hello local") }).pipe(Effect.provide(live)) }), @@ -106,13 +98,13 @@ describe("makeTui", () => { Effect.gen(function* () { const gate = yield* Deferred.make() const tui = yield* makeTui - const poolLayer = makePoolLayer({ gate }) + const threadChatLayer = makeThreadChatLayer({ gate }) const live = Conversation.layer.pipe( Layer.provideMerge(tui.layer), - Layer.provideMerge(ConversationLedger.noop), + Layer.provideMerge(IngressDedup.noop), Layer.provideMerge(routerLayer), - Layer.provideMerge(poolLayer), + Layer.provideMerge(threadChatLayer), Layer.provideMerge(testConfigLayer), ) @@ -134,13 +126,13 @@ describe("makeTui", () => { effectTest("channel messages create distinct threads", () => Effect.gen(function* () { const tui = yield* makeTui - const poolLayer = makePoolLayer({}) + const threadChatLayer = makeThreadChatLayer({}) const live = Conversation.layer.pipe( Layer.provideMerge(tui.layer), - Layer.provideMerge(ConversationLedger.noop), + Layer.provideMerge(IngressDedup.noop), Layer.provideMerge(routerLayer), - Layer.provideMerge(poolLayer), + Layer.provideMerge(threadChatLayer), Layer.provideMerge(testConfigLayer), ) @@ -161,7 +153,7 @@ describe("makeTui", () => { expect(secondTyping.kind).toBe("typing") expect(secondSend.kind).toBe("send") if (firstTyping.kind === "typing" && secondTyping.kind === "typing") { - expect(firstTyping.thread_id === secondTyping.thread_id).toBe(false) + expect(firstTyping.threadId === secondTyping.threadId).toBe(false) } }).pipe(Effect.provide(live)) }), diff --git a/packages/discord/src/conversation/implementations/local/index.ts b/packages/discord/src/control/local-adapter.ts similarity index 61% rename from packages/discord/src/conversation/implementations/local/index.ts rename to packages/discord/src/control/local-adapter.ts index 13d74a2ad2..23d2419b6c 100644 --- a/packages/discord/src/conversation/implementations/local/index.ts +++ b/packages/discord/src/control/local-adapter.ts @@ -1,13 +1,13 @@ import { Effect, Layer, Queue, Schedule, Stream } from "effect" -import { TYPING_INTERVAL } from "../../../discord/constants" -import { ChannelId, GuildId, ThreadId } from "../../../types" -import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../../model/schema" -import { History, Inbox, Outbox, Threads } from "../../services" +import { TYPING_INTERVAL } from "../discord/constants" +import { ChannelId, GuildId, ThreadId } from "../types" +import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../conversation/model/schema" +import { History, Inbox, Outbox, Threads } from "../conversation" export type Tui = { layer: Layer.Layer send: (text: string) => Effect.Effect - sendTo: (thread_id: ThreadId, text: string) => Effect.Effect + sendTo: (threadId: ThreadId, text: string) => Effect.Effect take: Effect.Effect actions: Stream.Stream } @@ -33,32 +33,32 @@ export const makeTui = Effect.gen(function* () { return ThreadId.make(`thread-${x}-${y}-${z}`) } - const remember = (thread_id: ThreadId, line: string) => { - const current = history.get(thread_id) + const remember = (threadId: ThreadId, line: string) => { + const current = history.get(threadId) if (current) { current.push(line) return } - history.set(thread_id, [line]) + history.set(threadId, [line]) } - const sendTo = (thread_id: ThreadId, text: string) => + const sendTo = (threadId: ThreadId, text: string) => Effect.gen(function* () { - remember(thread_id, `user: ${text}`) - const channel_id = parents.get(thread_id) ?? ChannelId.make(`channel-${thread_id}`) + remember(threadId, `user: ${text}`) + const channelId = parents.get(threadId) ?? ChannelId.make(`channel-${threadId}`) yield* input.offer( ThreadMessage.make({ kind: "thread_message", - thread_id, - channel_id, - message_id: crypto.randomUUID(), - guild_id: GuildId.make("local"), - bot_user_id: "local-bot", - bot_role_id: "", - author_id: "local-user", - author_is_bot: false, - mentions_everyone: false, - mentions: Mention.make({ user_ids: [], role_ids: [] }), + threadId, + channelId, + messageId: crypto.randomUUID(), + guildId: GuildId.make("local"), + botUserId: "local-bot", + botRoleId: "", + authorId: "local-user", + authorIsBot: false, + mentionsEveryone: false, + mentions: Mention.make({ userIds: [], roleIds: [] }), content: text, }), ).pipe(Effect.asVoid) @@ -66,19 +66,19 @@ export const makeTui = Effect.gen(function* () { const send = (text: string) => Effect.gen(function* () { - const channel_id = ChannelId.make("local-channel") + const channelId = ChannelId.make("local-channel") yield* input.offer( ChannelMessage.make({ kind: "channel_message", - channel_id, - message_id: crypto.randomUUID(), - guild_id: GuildId.make("local"), - bot_user_id: "local-bot", - bot_role_id: "", - author_id: "local-user", - author_is_bot: false, - mentions_everyone: false, - mentions: Mention.make({ user_ids: ["local-bot"], role_ids: [] }), + channelId, + messageId: crypto.randomUUID(), + guildId: GuildId.make("local"), + botUserId: "local-bot", + botRoleId: "", + authorId: "local-user", + authorIsBot: false, + mentionsEveryone: false, + mentions: Mention.make({ userIds: ["local-bot"], roleIds: [] }), content: text, }), ).pipe(Effect.asVoid) @@ -97,17 +97,17 @@ export const makeTui = Effect.gen(function* () { publish: (action) => Effect.gen(function* () { if (action.kind === "send" || action.kind === "reply") { - remember(action.thread_id, `assistant: ${action.text}`) + remember(action.threadId, `assistant: ${action.text}`) } yield* output.offer(action).pipe(Effect.asVoid) }), - withTyping: (thread_id: ThreadId, self: Effect.Effect) => + withTyping: (threadId: ThreadId, self: Effect.Effect) => Effect.scoped( Effect.gen(function* () { const pulse = output.offer( Typing.make({ kind: "typing", - thread_id, + threadId, }), ).pipe(Effect.asVoid) yield* pulse @@ -124,9 +124,9 @@ export const makeTui = Effect.gen(function* () { Layer.succeed( History, History.of({ - rehydrate: (thread_id, latest: string) => + rehydrate: (threadId, latest: string) => Effect.sync(() => { - const lines = history.get(thread_id) ?? [] + const lines = history.get(threadId) ?? [] const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines if (prior.length === 0) return latest return [ @@ -145,15 +145,15 @@ export const makeTui = Effect.gen(function* () { ensure: (event) => Effect.sync(() => { if (event.kind === "thread_message") { - parents.set(event.thread_id, event.channel_id) - return ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }) + parents.set(event.threadId, event.channelId) + return ThreadRef.make({ threadId: event.threadId, channelId: event.channelId }) } - const known = roots.get(event.message_id) - if (known) return ThreadRef.make({ thread_id: known, channel_id: event.channel_id }) - const thread_id = name() - roots.set(event.message_id, thread_id) - parents.set(thread_id, event.channel_id) - return ThreadRef.make({ thread_id, channel_id: event.channel_id }) + const known = roots.get(event.messageId) + if (known) return ThreadRef.make({ threadId: known, channelId: event.channelId }) + const threadId = name() + roots.set(event.messageId, threadId) + parents.set(threadId, event.channelId) + return ThreadRef.make({ threadId, channelId: event.channelId }) }), }), ), diff --git a/packages/discord/src/conversation/control/state.test.ts b/packages/discord/src/control/state.test.ts similarity index 68% rename from packages/discord/src/conversation/control/state.test.ts rename to packages/discord/src/control/state.test.ts index d703aa6b2e..0d78f3bf5a 100644 --- a/packages/discord/src/conversation/control/state.test.ts +++ b/packages/discord/src/control/state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" -import { ThreadId } from "../../types" -import { Send, Typing } from "../model/schema" +import { ThreadId } from "../types" +import { Send, Typing } from "../conversation/model/schema" import { autoThread, base, channelFrom, parse, prompt, queueTarget, scopeText, threadFrom } from "./state" describe("cli-state", () => { @@ -12,18 +12,18 @@ describe("cli-state", () => { expect(parse("/pick")).toEqual({ kind: "pick", index: null }) expect(parse("/pick 2")).toEqual({ kind: "pick", index: 2 }) expect(parse("/active")).toEqual({ kind: "active" }) - expect(parse("/thread")).toEqual({ kind: "thread", thread_id: null }) - expect(parse("/thread abc")).toEqual({ kind: "thread", thread_id: ThreadId.make("abc") }) - expect(parse("/status")).toEqual({ kind: "status", thread_id: null }) - expect(parse("/status abc")).toEqual({ kind: "status", thread_id: ThreadId.make("abc") }) - expect(parse("/logs")).toEqual({ kind: "logs", lines: 120, thread_id: null }) - expect(parse("/logs 80")).toEqual({ kind: "logs", lines: 80, thread_id: null }) - expect(parse("/logs abc")).toEqual({ kind: "logs", lines: 120, thread_id: ThreadId.make("abc") }) - expect(parse("/logs 80 abc")).toEqual({ kind: "logs", lines: 80, thread_id: ThreadId.make("abc") }) - expect(parse("/pause")).toEqual({ kind: "pause", thread_id: null }) - expect(parse("/destroy")).toEqual({ kind: "destroy", thread_id: null }) - expect(parse("/resume")).toEqual({ kind: "resume", thread_id: null }) - expect(parse("/restart")).toEqual({ kind: "restart", thread_id: null }) + expect(parse("/thread")).toEqual({ kind: "thread", threadId: null }) + expect(parse("/thread abc")).toEqual({ kind: "thread", threadId: ThreadId.make("abc") }) + expect(parse("/status")).toEqual({ kind: "status", threadId: null }) + expect(parse("/status abc")).toEqual({ kind: "status", threadId: ThreadId.make("abc") }) + expect(parse("/logs")).toEqual({ kind: "logs", lines: 120, threadId: null }) + expect(parse("/logs 80")).toEqual({ kind: "logs", lines: 80, threadId: null }) + expect(parse("/logs abc")).toEqual({ kind: "logs", lines: 120, threadId: ThreadId.make("abc") }) + expect(parse("/logs 80 abc")).toEqual({ kind: "logs", lines: 80, threadId: ThreadId.make("abc") }) + expect(parse("/pause")).toEqual({ kind: "pause", threadId: null }) + expect(parse("/recreate")).toEqual({ kind: "recreate", threadId: null }) + expect(parse("/destroy")).toEqual({ kind: "recreate", threadId: null }) + expect(parse("/resume")).toEqual({ kind: "resume", threadId: null }) expect(parse("/nope")).toEqual({ kind: "unknown", name: "nope" }) }) @@ -41,8 +41,8 @@ describe("cli-state", () => { it("auto switches from channel to thread on action", () => { const a = base() - const typing = Typing.make({ kind: "typing", thread_id: ThreadId.make("t-a") }) - const send = Send.make({ kind: "send", thread_id: ThreadId.make("t-b"), text: "ok" }) + const typing = Typing.make({ kind: "typing", threadId: ThreadId.make("t-a") }) + const send = Send.make({ kind: "send", threadId: ThreadId.make("t-b"), text: "ok" }) expect(autoThread(a, typing)).toEqual(threadFrom(a, ThreadId.make("t-a"))) expect(autoThread(a, send)).toEqual(threadFrom(a, ThreadId.make("t-b"))) diff --git a/packages/discord/src/conversation/control/state.ts b/packages/discord/src/control/state.ts similarity index 55% rename from packages/discord/src/conversation/control/state.ts rename to packages/discord/src/control/state.ts index 082d27cf91..293c3a68dd 100644 --- a/packages/discord/src/conversation/control/state.ts +++ b/packages/discord/src/control/state.ts @@ -1,11 +1,11 @@ -import { ThreadId } from "../../types" -import type { Action } from "../model/schema" +import { ThreadId } from "../types" +import type { Action } from "../conversation/model/schema" const LOCAL_CHANNEL = "local-channel" as const export type Scope = - | { kind: "channel"; channel_id: typeof LOCAL_CHANNEL } - | { kind: "thread"; thread_id: ThreadId; channel_id: typeof LOCAL_CHANNEL } + | { kind: "channel"; channelId: typeof LOCAL_CHANNEL } + | { kind: "thread"; threadId: ThreadId; channelId: typeof LOCAL_CHANNEL } export type Command = | { kind: "channel" } @@ -13,16 +13,15 @@ export type Command = | { kind: "threads" } | { kind: "pick"; index: number | null } | { kind: "active" } - | { kind: "thread"; thread_id: ThreadId | null } - | { kind: "status"; thread_id: ThreadId | null } - | { kind: "logs"; thread_id: ThreadId | null; lines: number } - | { kind: "pause"; thread_id: ThreadId | null } - | { kind: "destroy"; thread_id: ThreadId | null } - | { kind: "resume"; thread_id: ThreadId | null } - | { kind: "restart"; thread_id: ThreadId | null } + | { kind: "thread"; threadId: ThreadId | null } + | { kind: "status"; threadId: ThreadId | null } + | { kind: "logs"; threadId: ThreadId | null; lines: number } + | { kind: "pause"; threadId: ThreadId | null } + | { kind: "recreate"; threadId: ThreadId | null } + | { kind: "resume"; threadId: ThreadId | null } | { kind: "unknown"; name: string } -export const base = (): Scope => ({ kind: "channel", channel_id: LOCAL_CHANNEL }) +export const base = (): Scope => ({ kind: "channel", channelId: LOCAL_CHANNEL }) const target = (value: string | undefined) => { const raw = value?.trim() ?? "" @@ -56,49 +55,48 @@ export const parse = (line: string): Command | null => { if (head === "threads") return { kind: "threads" } if (head === "pick") return { kind: "pick", index: parseIndex(args.at(0)) } if (head === "active") return { kind: "active" } - if (head === "thread") return { kind: "thread", thread_id: target(args.at(0)) } - if (head === "status") return { kind: "status", thread_id: target(args.at(0)) } + if (head === "thread") return { kind: "thread", threadId: target(args.at(0)) } + if (head === "status") return { kind: "status", threadId: target(args.at(0)) } if (head === "logs") { const lines = parseLines(args.at(0)) if (lines === null) { - return { kind: "logs", lines: 120, thread_id: target(args.at(0)) } + return { kind: "logs", lines: 120, threadId: target(args.at(0)) } } - return { kind: "logs", lines, thread_id: target(args.at(1)) } + return { kind: "logs", lines, threadId: target(args.at(1)) } } - if (head === "pause") return { kind: "pause", thread_id: target(args.at(0)) } - if (head === "destroy") return { kind: "destroy", thread_id: target(args.at(0)) } - if (head === "resume") return { kind: "resume", thread_id: target(args.at(0)) } - if (head === "restart") return { kind: "restart", thread_id: target(args.at(0)) } + if (head === "pause") return { kind: "pause", threadId: target(args.at(0)) } + if (head === "recreate" || head === "destroy") return { kind: "recreate", threadId: target(args.at(0)) } + if (head === "resume") return { kind: "resume", threadId: target(args.at(0)) } return { kind: "unknown", name: head } } export const scopeText = (scope: Scope) => scope.kind === "channel" - ? `channel:${scope.channel_id}` - : `thread:${scope.thread_id}` + ? `channel:${scope.channelId}` + : `thread:${scope.threadId}` export const prompt = (scope: Scope) => scope.kind === "channel" ? "channel> " - : `thread:${scope.thread_id}> ` + : `thread:${scope.threadId}> ` export const queueTarget = (scope: Scope) => scope.kind === "channel" ? "channel" : "thread" -export const threadFrom = (scope: Scope, thread_id: ThreadId): Scope => ({ +export const threadFrom = (scope: Scope, threadId: ThreadId): Scope => ({ kind: "thread", - channel_id: scope.channel_id, - thread_id, + channelId: scope.channelId, + threadId, }) export const channelFrom = (scope: Scope): Scope => ({ kind: "channel", - channel_id: scope.channel_id, + channelId: scope.channelId, }) export const autoThread = (scope: Scope, action: Action, known = false): Scope => { if (scope.kind === "thread") return scope if (action.kind !== "typing" && action.kind !== "send" && action.kind !== "reply") return scope if (known) return scope - return threadFrom(scope, action.thread_id) + return threadFrom(scope, action.threadId) } diff --git a/packages/discord/src/conversation/README.md b/packages/discord/src/conversation/README.md index 73d2522dc2..8c7dc36754 100644 --- a/packages/discord/src/conversation/README.md +++ b/packages/discord/src/conversation/README.md @@ -1,51 +1,25 @@ -# Conversation Experiment +# Conversation Runtime -This folder contains the active Discord conversation runtime. +This folder contains the active conversation runtime used by the Discord bot. -Goal: +## Active layout -- keep inbound transport as a stream (`Inbox.events`) -- keep outbound transport as actions (`Outbox.publish`) -- support first-contact channel messages by resolving a thread target through `Threads.ensure` -- move orchestration into a transport-agnostic `Conversation` service +- `model/*`: normalized inbound events + outbound actions and conversation error model. +- `conversation.ts`: core orchestration loop. +- `inbox.ts`, `outbox.ts`, `threads.ts`, `history.ts`, `dedup.ts`, `offsets.ts`: service contracts used by the runtime. +- `thread/cluster/*`: thread-scoped cluster runtime (`ThreadEntity`, `ThreadChatCluster`, `ThreadControlCluster`) for send/status/recreate/control. +- `../discord/adapter.ts`: Discord adapter implementation for inbox/outbox/thread/history ports. +- `../control/*`: local CLI/controller tooling. -Current status: +## Current execution path -- `model/schema.ts`: normalized event/action schema (`thread_message` and `channel_message`) -- `services/*`: service contracts (`Inbox`, `Outbox`, `History`, `Threads`, `ConversationLedger`) + `Conversation` -- `implementations/local/index.ts`: local implementation with `send()` / `take()` for non-Discord chat loops -- `implementations/discord/index.ts`: Discord implementation mapping message events to `Inbound` and actions to Discord sends -- `control/state.ts` + `control/cli.ts` + `control/controller.ts`: local CLI state, interactive CLI, and non-interactive controller commands +1. `Conversation.run` consumes `Inbox.events`. +2. `Conversation.turn` routes and resolves a thread target. +3. `Conversation` calls `ThreadChatCluster.send`. +4. `ThreadEntity` handles per-thread lifecycle/recovery/idempotent send. +5. `Outbox` publishes `typing` / `send` actions. -This module is wired into `src/index.ts`. +## Notes -Reliability semantics: - -- in-memory dedup by `message_id` prevents double-processing within a session -- startup catch-up fetches missed Discord messages from tracked thread sources and allowed channels using persisted offsets -- Discord is the durable inbox — on startup we resume from where we left off per source - -Local CLI notes (`bun run conversation:cli`): - -- `typing` now emits as soon as a target thread is resolved (before sandbox/session resolution), so startup latency is visible. -- channel and thread modes are explicit: - - `/channel` routes to top-level channel mode - - `/thread [id|n]` routes directly to thread mode (`n` is 1-based index from `/threads`; without arg, uses last seen thread) - - `/threads` lists known thread ids with indexes - - `/pick [n]` shows/selects a thread by index -- auto-switch from channel mode now only follows newly-seen threads (prevents jumping to old threads still emitting output) -- local thread simulation now mirrors Discord intent: each channel-mode message creates a new thread root, while explicit thread mode continues an existing thread -- local thread ids are human-readable (`thread-adjective-noun-n`) to make `/threads` easy to scan - -Agent CLI notes (`bun run conversation:controller` or `bun run conversation:ctl`): - -- non-interactive JSON output for automation (`ok: true/false`) -- commands: - - `active` - - `status --thread ` - - `logs --thread [--lines 120]` - - `pause --thread ` - - `destroy --thread ` - - `resume --thread [--channel --guild ]` - - `restart --thread ` - - `send --thread --text "" [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]` +- This module is wired from `packages/discord/src/index.ts`. +- Per-thread send idempotency is keyed by `messageId` in `thread/cluster` `send` RPC. diff --git a/packages/discord/src/conversation/services/conversation.test.ts b/packages/discord/src/conversation/conversation.test.ts similarity index 63% rename from packages/discord/src/conversation/services/conversation.test.ts rename to packages/discord/src/conversation/conversation.test.ts index 3081c8c847..be1ae70e9b 100644 --- a/packages/discord/src/conversation/services/conversation.test.ts +++ b/packages/discord/src/conversation/conversation.test.ts @@ -1,14 +1,13 @@ import { describe, expect } from "bun:test" -import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect" -import { TurnRouter, TurnRoutingDecision } from "../../discord/turn-routing" -import { DatabaseError, OpenCodeClientError, SandboxDeadError } from "../../errors" -import { ThreadAgentPool, type ThreadAgent } from "../../sandbox/pool" -import { effectTest, testConfigLayer } from "../../test/effect" -import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../../types" -import { Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../model/schema" -import { History } from "./history" +import { Deferred, Effect, Fiber, Layer, Option, Predicate, Stream } from "effect" +import { ThreadChatCluster, ThreadChatError } from "./thread/cluster" +import { TurnRouter, TurnRoutingDecision } from "../discord/turn-routing" +import { classifyOpenCodeFailure, DatabaseError, OpenCodeClientError, SandboxDeadError } from "../errors" +import { effectTest, testConfigLayer } from "../test/effect" +import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types" +import { Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "./model/schema" import { Inbox } from "./inbox" -import { ConversationLedger } from "./ledger" +import { IngressDedup } from "./dedup" import { Outbox } from "./outbox" import { Threads } from "./threads" import { Conversation } from "./conversation" @@ -35,16 +34,16 @@ const makeThreadEvent = (props: { }) => ThreadMessage.make({ kind: "thread_message", - thread_id: ThreadId.make(props.threadId), - channel_id: ChannelId.make(props.channelId), - message_id: props.messageId, - guild_id: GuildId.make("g1"), - bot_user_id: "bot-1", - bot_role_id: "role-1", - author_id: "u1", - author_is_bot: false, - mentions_everyone: false, - mentions: Mention.make({ user_ids: [], role_ids: [] }), + threadId: ThreadId.make(props.threadId), + channelId: ChannelId.make(props.channelId), + messageId: props.messageId, + guildId: GuildId.make("g1"), + botUserId: "bot-1", + botRoleId: "role-1", + authorId: "u1", + authorIsBot: false, + mentionsEveryone: false, + mentions: Mention.make({ userIds: [], roleIds: [] }), content: props.content, }) @@ -53,15 +52,15 @@ const makeEvent = (content: string) => const makeChannelEvent = (content: string) => ({ kind: "channel_message" as const, - channel_id: ChannelId.make("c-root"), - message_id: "m-root", - guild_id: GuildId.make("g1"), - bot_user_id: "bot-1", - bot_role_id: "role-1", - author_id: "u1", - author_is_bot: false, - mentions_everyone: false, - mentions: Mention.make({ user_ids: ["bot-1"], role_ids: [] }), + channelId: ChannelId.make("c-root"), + messageId: "m-root", + guildId: GuildId.make("g1"), + botUserId: "bot-1", + botRoleId: "role-1", + authorId: "u1", + authorIsBot: false, + mentionsEveryone: false, + mentions: Mention.make({ userIds: ["bot-1"], roleIds: [] }), content, }) @@ -80,28 +79,6 @@ const makeRouterLayer = (shouldRespond: boolean) => }), ) -const makeAgent = ( - session: SessionInfo, - send: ( - session: SessionInfo, - text: string, - ) => Effect.Effect, - prompts: Array, -): ThreadAgent => ({ - threadId: session.threadId, - session, - current: () => Effect.succeed(session), - send: (text: string) => - Effect.sync(() => { - prompts.push(text) - return { session, text } - }).pipe( - Effect.flatMap(({ session, text }) => send(session, text)), - ), - pause: () => Effect.void, - destroy: () => Effect.void, -}) - const makeConversationLayer = (props: { events: ReadonlyArray tracked: Option.Option @@ -115,8 +92,11 @@ const makeConversationLayer = (props: { shouldRespond?: boolean actions: Array prompts: Array + recreateCalls?: Array }) => { const resolveIndex = { value: 0 } + const state = new Map() + if (Option.isSome(props.tracked)) state.set(props.tracked.value.threadId, props.tracked.value) const inboxLayer = Layer.succeed( Inbox, @@ -132,12 +112,12 @@ const makeConversationLayer = (props: { Effect.sync(() => { props.actions.push(action) }), - withTyping: (thread_id: ThreadId, self: Effect.Effect) => + withTyping: (threadId: ThreadId, self: Effect.Effect) => Effect.gen(function* () { props.actions.push( Typing.make({ kind: "typing", - thread_id, + threadId, }), ) return yield* self @@ -145,21 +125,14 @@ const makeConversationLayer = (props: { }), ) - const historyLayer = Layer.succeed( - History, - History.of({ - rehydrate: props.rehydrate, - }), - ) - const threadsLayer = Layer.succeed( Threads, Threads.of({ ensure: (event) => { if (event.kind === "thread_message") { - return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id })) + return Effect.succeed(ThreadRef.make({ threadId: event.threadId, channelId: event.channelId })) } - return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id })) + return Effect.succeed(ThreadRef.make({ threadId: ThreadId.make("t-new"), channelId: event.channelId })) }, }), ) @@ -172,29 +145,69 @@ const makeConversationLayer = (props: { return session } - const poolLayer = Layer.succeed( - ThreadAgentPool, - ThreadAgentPool.of({ - getOrCreate: (threadId, channelId, guildId) => + const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => { + if (cause instanceof ThreadChatError) return cause + return ThreadChatError.make({ + threadId, + cause, + retriable: Predicate.isTagged(cause, "SandboxDeadError") || Predicate.isTagged(cause, "OpenCodeClientError"), + }) + } + + const threadChatLayer = Layer.succeed( + ThreadChatCluster, + ThreadChatCluster.of({ + send: (input) => Effect.sync(() => { - const session = resolveSession(threadId, channelId, guildId) - return makeAgent(session, props.send, props.prompts) + props.prompts.push(input.text) + const session = resolveSession(input.threadId, input.channelId, input.guildId) + return session + }).pipe( + Effect.flatMap((session) => + props.send(session, input.text).pipe( + Effect.map((text) => { + const prev = state.get(input.threadId) + state.set(input.threadId, session) + return { + text, + session, + changedSession: prev ? prev.sessionId !== session.sessionId : true, + } + }), + Effect.mapError((cause) => toThreadError(input.threadId, cause)), + )), + ), + status: (threadId) => + Effect.succeed( + Option.fromNullable( + state.get(threadId) ?? SessionInfo.make({ + threadId, + channelId: ChannelId.make("c1"), + guildId: GuildId.make("g1"), + sandboxId: SandboxId.make("sb1"), + sessionId: SessionId.make("s-tracked"), + previewUrl: "https://preview", + previewToken: null, + status: "active", + lastError: null, + resumeFailCount: 0, + }), + ), + ), + recreate: (threadId) => + Effect.sync(() => { + props.recreateCalls?.push(threadId) + state.delete(threadId) }), - hasTrackedThread: () => Effect.succeed(true), - getTrackedSession: () => Effect.succeed(props.tracked), - getActiveSessionCount: () => Effect.succeed(0), - pauseSession: () => Effect.void, - destroySession: () => Effect.void, }), ) return Conversation.layer.pipe( Layer.provideMerge(inboxLayer), Layer.provideMerge(outboxLayer), - Layer.provideMerge(historyLayer), - Layer.provideMerge(ConversationLedger.noop), + Layer.provideMerge(IngressDedup.noop), Layer.provideMerge(threadsLayer), - Layer.provideMerge(poolLayer), + Layer.provideMerge(threadChatLayer), Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)), Layer.provideMerge(testConfigLayer), ) @@ -223,12 +236,12 @@ describe("Conversation", () => { const sent = actions[1] if (!sent) throw new Error("missing send action") expect(sent.kind).toBe("send") - expect(sent.thread_id).toBe(ThreadId.make("t1")) + expect(sent.threadId).toBe(ThreadId.make("t1")) if (sent.kind === "send") expect(sent.text).toBe("echo:hello") }).pipe(Effect.provide(live)) }) - effectTest("turn rehydrates prompt when tracked and resolved sessions differ", () => { + effectTest("turn sends thread prompt through ThreadChatCluster", () => { const actions: Array = [] const prompts: Array = [] const live = makeConversationLayer({ @@ -245,17 +258,17 @@ describe("Conversation", () => { const conversation = yield* Conversation yield* conversation.turn(makeEvent("help me")) - expect(prompts).toEqual(["rehydrated:help me"]) + expect(prompts).toEqual(["help me"]) expect(actions.map((x) => x.kind)).toEqual(["typing", "send"]) const sent = actions[1] if (!sent) throw new Error("missing send action") expect(sent.kind).toBe("send") - expect(sent.thread_id).toBe(ThreadId.make("t1")) + expect(sent.threadId).toBe(ThreadId.make("t1")) if (sent.kind === "send") expect(sent.text).toBe("ok") }).pipe(Effect.provide(live)) }) - effectTest("turn recovers from dead sandbox by re-resolving and retrying", () => { + effectTest("turn returns retriable error for dead sandbox from ThreadChatCluster", () => { const actions: Array = [] const prompts: Array = [] const calls = { value: 0 } @@ -283,21 +296,11 @@ describe("Conversation", () => { return Effect.gen(function* () { const conversation = yield* Conversation - yield* conversation.turn(makeEvent("fix build")) + const out = yield* conversation.turn(makeEvent("fix build")).pipe(Effect.either) - expect(prompts).toEqual(["fix build", "rehydrated:fix build"]) - expect(actions.map((x) => x.kind)).toEqual(["typing", "send", "send"]) - const recovery = actions[1] - if (!recovery) throw new Error("missing recovery action") - expect(recovery.kind).toBe("send") - expect(recovery.thread_id).toBe(ThreadId.make("t1")) - if (recovery.kind === "send") expect(recovery.text).toBe("*Session changed state, recovering...*") - - const sent = actions[2] - if (!sent) throw new Error("missing final action") - expect(sent.kind).toBe("send") - expect(sent.thread_id).toBe(ThreadId.make("t1")) - if (sent.kind === "send") expect(sent.text).toBe("ok:rehydrated:fix build") + expect(out._tag).toBe("Left") + expect(prompts).toEqual(["fix build"]) + expect(actions.map((x) => x.kind)).toEqual(["typing"]) }).pipe(Effect.provide(live)) }) @@ -323,11 +326,65 @@ describe("Conversation", () => { const sent = actions[1] if (!sent) throw new Error("missing send action") expect(sent.kind).toBe("send") - expect(sent.thread_id).toBe(ThreadId.make("t-new")) + expect(sent.threadId).toBe(ThreadId.make("t-new")) if (sent.kind === "send") expect(sent.text).toBe("echo:from channel") }).pipe(Effect.provide(live)) }) + effectTest("!status is handled as a command without sending to agent", () => { + const actions: Array = [] + const prompts: Array = [] + const live = makeConversationLayer({ + events: [], + tracked: Option.some(makeSession("s1")), + resolves: [makeSession("s1")], + send: (_session, text) => Effect.succeed(`echo:${text}`), + rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`), + actions, + prompts, + }) + + return Effect.gen(function* () { + const conversation = yield* Conversation + yield* conversation.turn(makeEvent("!status")) + + expect(prompts).toEqual([]) + expect(actions.filter((action) => action.kind === "send").length).toBe(1) + const sent = actions.find((action) => action.kind === "send") + if (!sent || sent.kind !== "send") throw new Error("missing status output") + expect(sent.text.includes("**Status:**")).toBe(true) + expect(sent.text.includes("**Sandbox:**")).toBe(true) + expect(sent.text.includes("**Session:**")).toBe(true) + }).pipe(Effect.provide(live)) + }) + + effectTest("!recreate invokes recreate command and does not call agent", () => { + const actions: Array = [] + const prompts: Array = [] + const recreateCalls: Array = [] + const live = makeConversationLayer({ + events: [], + tracked: Option.some(makeSession("s1")), + resolves: [makeSession("s1")], + send: (_session, text) => Effect.succeed(`echo:${text}`), + rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`), + actions, + prompts, + recreateCalls, + }) + + return Effect.gen(function* () { + const conversation = yield* Conversation + yield* conversation.turn(makeEvent("!recreate")) + + expect(prompts).toEqual([]) + expect(recreateCalls).toEqual([ThreadId.make("t1")]) + const sent = actions.find((action) => action.kind === "send") + if (!sent || sent.kind !== "send") throw new Error("missing recreate output") + expect(sent.text.includes("Session recreated")).toBe(true) + }).pipe(Effect.provide(live)) + }) + effectTest("run retries retriable failures in-process", () => { const actions: Array = [] const prompts: Array = [] @@ -340,11 +397,15 @@ describe("Conversation", () => { Effect.gen(function* () { calls.value += 1 if (calls.value === 1) { - return yield* Effect.fail(new OpenCodeClientError({ + return yield* new OpenCodeClientError({ operation: "sendPrompt", statusCode: 502, body: "StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)", - })) + kind: classifyOpenCodeFailure( + 502, + "StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)", + ), + }) } return `ok:${text}` }), @@ -362,7 +423,7 @@ describe("Conversation", () => { const sent = actions[2] if (!sent) throw new Error("missing send action") expect(sent.kind).toBe("send") - expect(sent.thread_id).toBe(ThreadId.make("t1")) + expect(sent.threadId).toBe(ThreadId.make("t1")) if (sent.kind === "send") expect(sent.text).toBe("ok:retry now") }).pipe(Effect.provide(live)) }) @@ -392,7 +453,7 @@ describe("Conversation", () => { const sent = actions[1] if (!sent) throw new Error("missing send action") expect(sent.kind).toBe("send") - expect(sent.thread_id).toBe(ThreadId.make("t1")) + expect(sent.threadId).toBe(ThreadId.make("t1")) if (sent.kind === "send") { expect(sent.text).toBe("Something went wrong. Please try again in a moment.") expect(sent.text.includes("proxy.daytona.works")).toBe(false) @@ -447,27 +508,25 @@ describe("Conversation", () => { // --- Duplicate processing tests --- -/** A ledger that tracks dedup calls */ -const makeTrackingLedger = () => { +/** A dedup service that tracks dedup calls */ +const makeTrackingDedup = () => { const seen = new Set() const dedupCalls: Array = [] - const service: ConversationLedger.Service = { - dedup: (message_id) => + const service: IngressDedup.Service = { + dedup: (messageId) => Effect.sync(() => { - dedupCalls.push(message_id) - if (seen.has(message_id)) return false - seen.add(message_id) + dedupCalls.push(messageId) + if (seen.has(messageId)) return false + seen.add(messageId) return true }), - getOffset: () => Effect.succeed(Option.none()), - setOffset: () => Effect.void, } return { service, seen, dedupCalls } } -const makeConversationLayerWithLedger = (props: { +const makeConversationLayerWithDedup = (props: { events: ReadonlyArray tracked: Option.Option resolves: ReadonlyArray @@ -480,9 +539,11 @@ const makeConversationLayerWithLedger = (props: { shouldRespond?: boolean actions: Array prompts: Array - ledger: ConversationLedger.Service + dedup: IngressDedup.Service }) => { const resolveIndex = { value: 0 } + const state = new Map() + if (Option.isSome(props.tracked)) state.set(props.tracked.value.threadId, props.tracked.value) const inboxLayer = Layer.succeed( Inbox, @@ -498,12 +559,12 @@ const makeConversationLayerWithLedger = (props: { Effect.sync(() => { props.actions.push(action) }), - withTyping: (thread_id: ThreadId, self: Effect.Effect) => + withTyping: (threadId: ThreadId, self: Effect.Effect) => Effect.gen(function* () { props.actions.push( Typing.make({ kind: "typing", - thread_id, + threadId, }), ) return yield* self @@ -511,21 +572,14 @@ const makeConversationLayerWithLedger = (props: { }), ) - const historyLayer = Layer.succeed( - History, - History.of({ - rehydrate: props.rehydrate, - }), - ) - const threadsLayer = Layer.succeed( Threads, Threads.of({ ensure: (event) => { if (event.kind === "thread_message") { - return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id })) + return Effect.succeed(ThreadRef.make({ threadId: event.threadId, channelId: event.channelId })) } - return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id })) + return Effect.succeed(ThreadRef.make({ threadId: ThreadId.make("t-new"), channelId: event.channelId })) }, }), ) @@ -538,31 +592,70 @@ const makeConversationLayerWithLedger = (props: { return session } - const poolLayer = Layer.succeed( - ThreadAgentPool, - ThreadAgentPool.of({ - getOrCreate: (threadId, channelId, guildId) => + const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => { + if (cause instanceof ThreadChatError) return cause + return ThreadChatError.make({ + threadId, + cause, + retriable: Predicate.isTagged(cause, "SandboxDeadError") || Predicate.isTagged(cause, "OpenCodeClientError"), + }) + } + + const threadChatLayer = Layer.succeed( + ThreadChatCluster, + ThreadChatCluster.of({ + send: (input) => Effect.sync(() => { - const session = resolveSession(threadId, channelId, guildId) - return makeAgent(session, props.send, props.prompts) + props.prompts.push(input.text) + const session = resolveSession(input.threadId, input.channelId, input.guildId) + return session + }).pipe( + Effect.flatMap((session) => + props.send(session, input.text).pipe( + Effect.map((text) => { + const prev = state.get(input.threadId) + state.set(input.threadId, session) + return { + text, + session, + changedSession: prev ? prev.sessionId !== session.sessionId : true, + } + }), + Effect.mapError((cause) => toThreadError(input.threadId, cause)), + )), + ), + status: (threadId) => + Effect.succeed( + Option.fromNullable( + state.get(threadId) ?? SessionInfo.make({ + threadId, + channelId: ChannelId.make("c1"), + guildId: GuildId.make("g1"), + sandboxId: SandboxId.make("sb1"), + sessionId: SessionId.make("s-tracked"), + previewUrl: "https://preview", + previewToken: null, + status: "active", + lastError: null, + resumeFailCount: 0, + }), + ), + ), + recreate: (threadId) => + Effect.sync(() => { + state.delete(threadId) }), - hasTrackedThread: () => Effect.succeed(true), - getTrackedSession: () => Effect.succeed(props.tracked), - getActiveSessionCount: () => Effect.succeed(0), - pauseSession: () => Effect.void, - destroySession: () => Effect.void, }), ) - const ledgerLayer = Layer.succeed(ConversationLedger, ConversationLedger.of(props.ledger)) + const dedupLayer = Layer.succeed(IngressDedup, IngressDedup.of(props.dedup)) return Conversation.layer.pipe( Layer.provideMerge(inboxLayer), Layer.provideMerge(outboxLayer), - Layer.provideMerge(historyLayer), - Layer.provideMerge(ledgerLayer), + Layer.provideMerge(dedupLayer), Layer.provideMerge(threadsLayer), - Layer.provideMerge(poolLayer), + Layer.provideMerge(threadChatLayer), Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)), Layer.provideMerge(testConfigLayer), ) @@ -572,10 +665,10 @@ describe("Conversation duplicate processing", () => { effectTest("same message_id queued twice via run is only sent once", () => { const actions: Array = [] const prompts: Array = [] - const ledger = makeTrackingLedger() + const dedup = makeTrackingDedup() const event = makeEvent("hello") - const live = makeConversationLayerWithLedger({ + const live = makeConversationLayerWithDedup({ // Feed the same event twice to simulate catch-up + real-time race events: [event, event], tracked: Option.none(), @@ -584,7 +677,7 @@ describe("Conversation duplicate processing", () => { rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`), actions, prompts, - ledger: ledger.service, + dedup: dedup.service, }) return Effect.gen(function* () { @@ -592,7 +685,7 @@ describe("Conversation duplicate processing", () => { yield* conversation.run // The ledger should have been called twice with dedup - expect(ledger.dedupCalls).toEqual(["m1", "m1"]) + expect(dedup.dedupCalls).toEqual(["m1", "m1"]) // The agent should have received the prompt only once expect(prompts).toEqual(["hello"]) // Only one typing + one send @@ -600,12 +693,12 @@ describe("Conversation duplicate processing", () => { }).pipe(Effect.provide(live)) }) - effectTest("noop ledger deduplicates", () => { + effectTest("noop dedup service deduplicates", () => { const actions: Array = [] const prompts: Array = [] const event = makeEvent("hello") - // The noop ledger tracks seen message_ids + // The noop dedup service tracks seen message_ids const live = makeConversationLayer({ events: [event, event], tracked: Option.none(), @@ -626,13 +719,13 @@ describe("Conversation duplicate processing", () => { }).pipe(Effect.provide(live)) }) - effectTest("turn called twice with same event only processes once with tracking ledger", () => { + effectTest("turn called twice with same event only processes once with tracking dedup service", () => { const actions: Array = [] const prompts: Array = [] - const ledger = makeTrackingLedger() + const dedup = makeTrackingDedup() const event = makeEvent("help me") - const live = makeConversationLayerWithLedger({ + const live = makeConversationLayerWithDedup({ events: [], tracked: Option.none(), resolves: [makeSession("s1")], @@ -640,7 +733,7 @@ describe("Conversation duplicate processing", () => { rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`), actions, prompts, - ledger: ledger.service, + dedup: dedup.service, }) return Effect.gen(function* () { @@ -657,11 +750,11 @@ describe("Conversation duplicate processing", () => { effectTest("two different messages on same thread are processed sequentially (not lost)", () => { const actions: Array = [] const prompts: Array = [] - const ledger = makeTrackingLedger() + const dedup = makeTrackingDedup() const event1 = makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m1", content: "first" }) const event2 = makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m2", content: "second" }) - const live = makeConversationLayerWithLedger({ + const live = makeConversationLayerWithDedup({ events: [event1, event2], tracked: Option.none(), resolves: [makeSession("s1")], @@ -669,7 +762,7 @@ describe("Conversation duplicate processing", () => { rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`), actions, prompts, - ledger: ledger.service, + dedup: dedup.service, }) return Effect.gen(function* () { diff --git a/packages/discord/src/conversation/conversation.ts b/packages/discord/src/conversation/conversation.ts new file mode 100644 index 0000000000..5ee516303c --- /dev/null +++ b/packages/discord/src/conversation/conversation.ts @@ -0,0 +1,270 @@ +import { Context, Effect, Layer, Option, Predicate, Schedule, Stream } from "effect" +import { ThreadChatCluster, ThreadChatError } from "./thread/cluster" +import { AppConfig } from "../config" +import { TurnRouter } from "../discord/turn-routing" +import type { HealthCheckError, OpenCodeClientError, SandboxDeadError, SandboxStartError } from "../errors" +import { ActorMap } from "../lib/actors/keyed" +import type { ChannelId, SessionInfo, ThreadId } from "../types" +import { type ConversationError, messageOf, RoutingError, SandboxSendError } from "./model/errors" +import { Send, type Inbound } from "./model/schema" +import { Inbox } from "./inbox" +import { IngressDedup } from "./dedup" +import { Outbox } from "./outbox" +import { Threads } from "./threads" + +export declare namespace Conversation { + export interface Service { + readonly turn: (event: Inbound) => Effect.Effect + readonly run: Effect.Effect + } +} + +export class Conversation extends Context.Tag("@discord/conversation/Conversation")< + Conversation, + Conversation.Service +>() { + static readonly layer = Layer.scoped( + Conversation, + Effect.gen(function* () { + const inbox = yield* Inbox + const outbox = yield* Outbox + const threads = yield* Threads + const dedup = yield* IngressDedup + const config = yield* AppConfig + const threadChat = yield* ThreadChatCluster + const router = yield* TurnRouter + const actors = yield* ActorMap.make() + const RETRY_MESSAGE = "Something went wrong. Please try again in a moment." + const turnRetry = Schedule.exponential("500 millis").pipe( + Schedule.intersect(Schedule.recurs(2)), + Schedule.whileInput((error: ConversationError) => error.retriable), + ) + + type RetriableCause = SandboxDeadError | OpenCodeClientError | HealthCheckError | SandboxStartError + + const isRetriableCause = (cause: unknown): cause is RetriableCause => + Predicate.isTagged(cause, "SandboxDeadError") || + Predicate.isTagged(cause, "OpenCodeClientError") || + Predicate.isTagged(cause, "HealthCheckError") || + Predicate.isTagged(cause, "SandboxStartError") + + const asSendError = + (threadId: ThreadId) => + (cause: unknown): SandboxSendError => { + if (cause instanceof ThreadChatError) { + return SandboxSendError.make({ + threadId, + message: messageOf(cause.cause), + retriable: cause.retriable, + }) + } + return SandboxSendError.make({ + threadId, + message: messageOf(cause), + retriable: isRetriableCause(cause), + }) + } + + const publishText = (threadId: ThreadId, text: string) => + outbox.publish( + Send.make({ + kind: "send", + threadId, + text, + }), + ) + + const renderStatus = (session: SessionInfo) => { + const model = config.openCodeModel.replace("opencode/", "") + return [ + `**Status:** ${session.status}`, + `**Model:** \`${model}\``, + `**Sandbox:** \`${session.sandboxId}\``, + `**Session:** \`${session.sessionId}\``, + session.resumeFailCount > 0 ? `**Resume failures:** ${session.resumeFailCount}` : null, + session.lastError ? `**Last error:** ${session.lastError.slice(0, 200)}` : null, + ] + .filter((line): line is string => line !== null) + .join("\n") + } + + const reportFailure = (threadId: ThreadId) => (error: ConversationError) => { + if (error.retriable) return Effect.fail(error) + return publishText(threadId, RETRY_MESSAGE).pipe( + Effect.catchAll(() => Effect.void), + Effect.zipRight(Effect.fail(error)), + ) + } + + const route = Effect.fn("Conversation.route")(function* (event: Inbound) { + if (event.authorIsBot) return false + if (event.mentionsEveryone) return false + if (!event.content.trim()) return false + + const mentioned = + event.mentions.userIds.includes(event.botUserId) || + (event.botRoleId.length > 0 && event.mentions.roleIds.includes(event.botRoleId)) + if (event.kind === "channel_message") return mentioned + if (mentioned) return true + + const owned = yield* threadChat + .status(event.threadId) + .pipe( + Effect.map((session) => Option.isSome(session)), + Effect.mapError(asSendError(event.threadId)), + ) + if (!owned) return false + + const decision = yield* router + .shouldRespond({ + content: event.content, + botUserId: event.botUserId, + botRoleId: event.botRoleId, + mentionedUserIds: event.mentions.userIds, + mentionedRoleIds: event.mentions.roleIds, + }) + .pipe( + Effect.mapError((cause) => + RoutingError.make({ + message: messageOf(cause), + retriable: false, + }), + ), + ) + return decision.shouldRespond + }) + + const resolve = Effect.fn("Conversation.resolve")(function* (event: Inbound) { + if (event.kind === "thread_message") { + return { threadId: event.threadId, channelId: event.channelId } + } + const name = yield* router.generateThreadName(event.content) + return yield* threads.ensure(event, name) + }) + + const commandStatus = (threadId: ThreadId) => + Effect.gen(function* () { + const tracked = yield* threadChat + .status(threadId) + .pipe(Effect.catchAll(() => Effect.succeed(Option.none()))) + if (Option.isNone(tracked)) { + yield* publishText(threadId, "*No active session for this thread.*") + return + } + yield* publishText(threadId, renderStatus(tracked.value)) + }) + + const commandRecreate = (threadId: ThreadId) => + threadChat.recreate(threadId).pipe( + Effect.catchAll(() => Effect.void), + Effect.zipRight( + publishText(threadId, "*☠️ Session recreated. Next message will provision a fresh sandbox.*"), + ), + ) + + const commands = { + "!status": commandStatus, + "!reset": commandRecreate, + "!recreate": commandRecreate, + } as const + type CommandName = keyof typeof commands + const isCommand = (text: string): text is CommandName => text in commands + + const command = (event: Inbound, target: { threadId: ThreadId; channelId: ChannelId }) => + Effect.gen(function* () { + const text = event.content.trim().toLowerCase() + if (!isCommand(text)) return false + yield* commands[text](target.threadId) + return true + }) + + const turnRaw = Effect.fn("Conversation.turnRaw")(function* (event: Inbound) { + if (!(yield* route(event))) return + + const target = yield* resolve(event) + + if (yield* command(event, target)) return + + yield* Effect.logInfo("User message").pipe( + Effect.annotateLogs({ + event: "conversation.user.message", + thread_id: target.threadId, + author_id: event.authorId, + content: event.content.slice(0, 200), + }), + ) + + yield* outbox + .withTyping( + target.threadId, + Effect.gen(function* () { + const reply = yield* threadChat.send({ + threadId: target.threadId, + channelId: target.channelId, + guildId: event.guildId, + messageId: event.messageId, + text: event.content, + }).pipe( + Effect.map((out) => out.text), + Effect.mapError(asSendError(target.threadId)), + ) + + yield* Effect.logInfo("Bot reply").pipe( + Effect.annotateLogs({ + event: "conversation.bot.reply", + thread_id: target.threadId, + content: reply.slice(0, 200), + }), + ) + yield* publishText(target.threadId, reply) + }), + ) + .pipe(Effect.catchAll(reportFailure(target.threadId))) + }) + + const keyOf = (event: Inbound) => + event.kind === "thread_message" ? `thread:${event.threadId}` : `channel:${event.channelId}` + + const processEvent = (event: Inbound) => actors.run(keyOf(event), turnRaw(event), { touch: false }) + const processFresh = (event: Inbound) => + dedup.dedup(event.messageId).pipe( + Effect.flatMap((fresh) => { + if (!fresh) return Effect.void + return processEvent(event) + }), + ) + + const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) { + yield* processFresh(event) + }) + + const run = inbox.events.pipe( + Stream.mapEffect( + (event) => + dedup.dedup(event.messageId).pipe( + Effect.flatMap((fresh) => { + if (!fresh) return Effect.void + return processEvent(event).pipe( + Effect.retry(turnRetry), + Effect.catchAll((error) => + Effect.logError("Conversation turn failed").pipe( + Effect.annotateLogs({ + event: "conversation.turn.failed", + tag: error._tag, + retriable: error.retriable, + message: error.message, + }), + ), + ), + ) + }), + ), + { concurrency: "unbounded", unordered: true }, + ), + Stream.runDrain, + ) + + return Conversation.of({ turn, run }) + }), + ) +} diff --git a/packages/discord/src/conversation/dedup.test.ts b/packages/discord/src/conversation/dedup.test.ts new file mode 100644 index 0000000000..246ffd7c7b --- /dev/null +++ b/packages/discord/src/conversation/dedup.test.ts @@ -0,0 +1,25 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { effectTest } from "../test/effect" +import { IngressDedup } from "./dedup" + +describe("IngressDedup", () => { + effectTest("dedup returns true first time, false second time", () => + Effect.gen(function* () { + const dedup = yield* IngressDedup + expect(yield* dedup.dedup("m1")).toBe(true) + expect(yield* dedup.dedup("m1")).toBe(false) + expect(yield* dedup.dedup("m2")).toBe(true) + expect(yield* dedup.dedup("m2")).toBe(false) + }).pipe(Effect.provide(IngressDedup.noop)), + ) + + effectTest("layer mode behaves as memory dedup", () => + Effect.gen(function* () { + const dedup = yield* IngressDedup + expect(yield* dedup.dedup("m1")).toBe(true) + expect(yield* dedup.dedup("m1")).toBe(false) + expect(yield* dedup.dedup("m2")).toBe(true) + }).pipe(Effect.provide(IngressDedup.layer)), + ) +}) diff --git a/packages/discord/src/conversation/dedup.ts b/packages/discord/src/conversation/dedup.ts new file mode 100644 index 0000000000..6c37c1a013 --- /dev/null +++ b/packages/discord/src/conversation/dedup.ts @@ -0,0 +1,35 @@ +import { Context, Effect, Layer } from "effect" + +const DEDUP_LIMIT = 4_000 + +const makeDedupSet = () => { + const seen = new Set() + const order: Array = [] + return (messageId: string): boolean => { + if (seen.has(messageId)) return false + seen.add(messageId) + order.push(messageId) + if (order.length > DEDUP_LIMIT) { + const oldest = order.shift() + if (oldest) seen.delete(oldest) + } + return true + } +} + +export declare namespace IngressDedup { + export interface Service { + readonly dedup: (messageId: string) => Effect.Effect + } +} + +export class IngressDedup extends Context.Tag("@discord/conversation/IngressDedup")() { + static readonly noop = Layer.sync(IngressDedup, () => { + const check = makeDedupSet() + return IngressDedup.of({ + dedup: (messageId) => Effect.sync(() => check(messageId)), + }) + }) + + static readonly layer = IngressDedup.noop +} diff --git a/packages/discord/src/conversation/services/history.ts b/packages/discord/src/conversation/history.ts similarity index 52% rename from packages/discord/src/conversation/services/history.ts rename to packages/discord/src/conversation/history.ts index 08bf70f23b..3c17a35ba9 100644 --- a/packages/discord/src/conversation/services/history.ts +++ b/packages/discord/src/conversation/history.ts @@ -1,10 +1,10 @@ import { Context, Effect, Layer } from "effect" -import type { ThreadId } from "../../types" -import type { HistoryError } from "../model/errors" +import type { ThreadId } from "../types" +import type { HistoryError } from "./model/errors" export declare namespace History { export interface Service { - readonly rehydrate: (thread_id: ThreadId, latest: string) => Effect.Effect + readonly rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect } } @@ -12,7 +12,7 @@ export class History extends Context.Tag("@discord/conversation/History") Effect.succeed(latest), + rehydrate: (_threadId: ThreadId, latest: string) => Effect.succeed(latest), }), ) } diff --git a/packages/discord/src/conversation/services/inbox.ts b/packages/discord/src/conversation/inbox.ts similarity index 89% rename from packages/discord/src/conversation/services/inbox.ts rename to packages/discord/src/conversation/inbox.ts index 787c75eaaf..a6c91c87e5 100644 --- a/packages/discord/src/conversation/services/inbox.ts +++ b/packages/discord/src/conversation/inbox.ts @@ -1,5 +1,5 @@ import { Context, Layer, Stream } from "effect" -import type { Inbound } from "../model/schema" +import type { Inbound } from "./model/schema" export declare namespace Inbox { export interface Service { diff --git a/packages/discord/src/conversation/services/index.ts b/packages/discord/src/conversation/index.ts similarity index 69% rename from packages/discord/src/conversation/services/index.ts rename to packages/discord/src/conversation/index.ts index dbd297fc40..ea897928ff 100644 --- a/packages/discord/src/conversation/services/index.ts +++ b/packages/discord/src/conversation/index.ts @@ -1,6 +1,7 @@ export { Conversation } from "./conversation" export { History } from "./history" export { Inbox } from "./inbox" -export { ConversationLedger } from "./ledger" +export { IngressDedup } from "./dedup" +export { OffsetStore } from "./offsets" export { Outbox } from "./outbox" export { Threads } from "./threads" diff --git a/packages/discord/src/conversation/model/errors.ts b/packages/discord/src/conversation/model/errors.ts index 0fe36c87c5..0a080cda16 100644 --- a/packages/discord/src/conversation/model/errors.ts +++ b/packages/discord/src/conversation/model/errors.ts @@ -6,7 +6,7 @@ const DeliveryAction = Schema.Literal("typing", "send", "reply") export class ThreadEnsureError extends Schema.TaggedError()( "ThreadEnsureError", { - channel_id: ChannelId, + channelId: ChannelId, message: Schema.String, retriable: Schema.Boolean, }, @@ -15,7 +15,7 @@ export class ThreadEnsureError extends Schema.TaggedError()( export class HistoryError extends Schema.TaggedError()( "HistoryError", { - thread_id: ThreadId, + threadId: ThreadId, message: Schema.String, retriable: Schema.Boolean, }, @@ -24,7 +24,7 @@ export class HistoryError extends Schema.TaggedError()( export class DeliveryError extends Schema.TaggedError()( "DeliveryError", { - thread_id: ThreadId, + threadId: ThreadId, action: DeliveryAction, message: Schema.String, retriable: Schema.Boolean, @@ -42,7 +42,7 @@ export class RoutingError extends Schema.TaggedError()( export class SandboxSendError extends Schema.TaggedError()( "SandboxSendError", { - thread_id: ThreadId, + threadId: ThreadId, message: Schema.String, retriable: Schema.Boolean, }, diff --git a/packages/discord/src/conversation/model/schema.ts b/packages/discord/src/conversation/model/schema.ts index ac7d8a827c..004ffb0643 100644 --- a/packages/discord/src/conversation/model/schema.ts +++ b/packages/discord/src/conversation/model/schema.ts @@ -2,35 +2,35 @@ import { Schema } from "effect" import { ThreadId, ChannelId, GuildId } from "../../types" export class Mention extends Schema.Class("Mention")({ - user_ids: Schema.Array(Schema.String), - role_ids: Schema.Array(Schema.String), + userIds: Schema.Array(Schema.String), + roleIds: Schema.Array(Schema.String), }) {} export class ThreadMessage extends Schema.Class("ThreadMessage")({ kind: Schema.Literal("thread_message"), - thread_id: ThreadId, - channel_id: ChannelId, - message_id: Schema.String, - guild_id: GuildId, - bot_user_id: Schema.String, - bot_role_id: Schema.String, - author_id: Schema.String, - author_is_bot: Schema.Boolean, - mentions_everyone: Schema.Boolean, + threadId: ThreadId, + channelId: ChannelId, + messageId: Schema.String, + guildId: GuildId, + botUserId: Schema.String, + botRoleId: Schema.String, + authorId: Schema.String, + authorIsBot: Schema.Boolean, + mentionsEveryone: Schema.Boolean, mentions: Mention, content: Schema.String, }) {} export class ChannelMessage extends Schema.Class("ChannelMessage")({ kind: Schema.Literal("channel_message"), - channel_id: ChannelId, - message_id: Schema.String, - guild_id: GuildId, - bot_user_id: Schema.String, - bot_role_id: Schema.String, - author_id: Schema.String, - author_is_bot: Schema.Boolean, - mentions_everyone: Schema.Boolean, + channelId: ChannelId, + messageId: Schema.String, + guildId: GuildId, + botUserId: Schema.String, + botRoleId: Schema.String, + authorId: Schema.String, + authorIsBot: Schema.Boolean, + mentionsEveryone: Schema.Boolean, mentions: Mention, content: Schema.String, }) {} @@ -43,25 +43,25 @@ export const Inbound = Schema.Union( export type Inbound = typeof Inbound.Type export class ThreadRef extends Schema.Class("ThreadRef")({ - thread_id: ThreadId, - channel_id: ChannelId, + threadId: ThreadId, + channelId: ChannelId, }) {} export class Send extends Schema.Class("Send")({ kind: Schema.Literal("send"), - thread_id: ThreadId, + threadId: ThreadId, text: Schema.String, }) {} export class Reply extends Schema.Class("Reply")({ kind: Schema.Literal("reply"), - thread_id: ThreadId, + threadId: ThreadId, text: Schema.String, }) {} export class Typing extends Schema.Class("Typing")({ kind: Schema.Literal("typing"), - thread_id: ThreadId, + threadId: ThreadId, }) {} export const Action = Schema.Union( diff --git a/packages/discord/src/conversation/offsets.test.ts b/packages/discord/src/conversation/offsets.test.ts new file mode 100644 index 0000000000..7e0e1f5acb --- /dev/null +++ b/packages/discord/src/conversation/offsets.test.ts @@ -0,0 +1,74 @@ +import { describe, expect } from "bun:test" +import { Duration, Effect, Layer, Option, Redacted } from "effect" +import { AppConfig } from "../config" +import { SqliteDb } from "../db/client" +import { effectTest, withTempSqliteFile } from "../test/effect" +import { OffsetStore } from "./offsets" + +const makeConfig = (databasePath: string) => + AppConfig.of({ + discordToken: Redacted.make("token"), + allowedChannelIds: [], + discordCategoryId: "", + discordRoleId: "", + discordRequiredRoleId: "", + discordCommandGuildId: "", + databasePath, + daytonaApiKey: Redacted.make("daytona"), + daytonaSnapshot: "", + openCodeZenApiKey: Redacted.make("zen"), + githubToken: "", + logLevel: "info", + healthHost: "127.0.0.1", + healthPort: 8787, + turnRoutingMode: "off", + turnRoutingModel: "claude-haiku-4-5", + sandboxReusePolicy: "resume_preferred", + sandboxTimeout: Duration.minutes(30), + cleanupInterval: Duration.minutes(5), + staleActiveGraceMinutes: 5 as AppConfig.Service["staleActiveGraceMinutes"], + pausedTtlMinutes: 180 as AppConfig.Service["pausedTtlMinutes"], + activeHealthCheckTimeoutMs: 15000 as AppConfig.Service["activeHealthCheckTimeoutMs"], + startupHealthTimeoutMs: 120000 as AppConfig.Service["startupHealthTimeoutMs"], + resumeHealthTimeoutMs: 120000 as AppConfig.Service["resumeHealthTimeoutMs"], + sandboxCreationTimeout: 180 as AppConfig.Service["sandboxCreationTimeout"], + openCodeModel: "opencode/claude-sonnet-4-5", + }) + +const withOffsets = ( + run: (offsets: OffsetStore.Service) => Effect.Effect, +) => + withTempSqliteFile((databasePath) => + Effect.gen(function* () { + const config = Layer.succeed(AppConfig, makeConfig(databasePath)) + const sqlite = SqliteDb.layer.pipe(Layer.provide(config)) + const deps = Layer.merge(sqlite, config) + const live = OffsetStore.layer.pipe(Layer.provide(deps)) + const program = Effect.flatMap(OffsetStore, (offsets) => run(offsets)) + return yield* program.pipe(Effect.provide(live)) + }), + "discord-offsets-", + ) + +describe("OffsetStore", () => { + effectTest("stores and updates source offsets", () => + withOffsets((offsets) => + Effect.gen(function* () { + expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true) + yield* offsets.setOffset("thread:t1", "m1") + expect(yield* offsets.getOffset("thread:t1")).toEqual(Option.some("m1")) + yield* offsets.setOffset("thread:t1", "m9") + expect(yield* offsets.getOffset("thread:t1")).toEqual(Option.some("m9")) + }), + ), + ) + + effectTest("noop offsets always return none", () => + Effect.gen(function* () { + const offsets = yield* OffsetStore + expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true) + yield* offsets.setOffset("thread:t1", "m1") + expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true) + }).pipe(Effect.provide(OffsetStore.noop)), + ) +}) diff --git a/packages/discord/src/conversation/offsets.ts b/packages/discord/src/conversation/offsets.ts new file mode 100644 index 0000000000..9e7168bae2 --- /dev/null +++ b/packages/discord/src/conversation/offsets.ts @@ -0,0 +1,57 @@ +import * as Client from "@effect/sql/SqlClient" +import { Context, Effect, Layer, Option } from "effect" +import { SqliteDb } from "../db/client" +import { initializeSchema } from "../db/init" +import { DatabaseError } from "../errors" + +const db = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause }))) + +export declare namespace OffsetStore { + export interface Service { + readonly getOffset: (source_id: string) => Effect.Effect, DatabaseError> + readonly setOffset: (source_id: string, messageId: string) => Effect.Effect + } +} + +export class OffsetStore extends Context.Tag("@discord/conversation/OffsetStore")() { + static readonly noop = Layer.succeed( + OffsetStore, + OffsetStore.of({ + getOffset: () => Effect.succeed(Option.none()), + setOffset: () => Effect.void, + }), + ) + + static readonly layer = Layer.effect( + OffsetStore, + Effect.gen(function* () { + const sql = yield* SqliteDb + yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql))) + + const getOffset = Effect.fn("OffsetStore.getOffset")(function* (source_id: string) { + const rows = yield* db( + sql<{ last_message_id: string }>`SELECT last_message_id + FROM conversation_offsets + WHERE source_id = ${source_id} + LIMIT 1`, + ) + const row = rows[0] + if (!row) return Option.none() + return Option.some(row.last_message_id) + }) + + const setOffset = Effect.fn("OffsetStore.setOffset")(function* (source_id: string, messageId: string) { + yield* db( + sql`INSERT INTO conversation_offsets (source_id, last_message_id, updated_at) + VALUES (${source_id}, ${messageId}, CURRENT_TIMESTAMP) + ON CONFLICT(source_id) DO UPDATE SET + last_message_id = excluded.last_message_id, + updated_at = CURRENT_TIMESTAMP`, + ) + }) + + return OffsetStore.of({ getOffset, setOffset }) + }), + ) +} diff --git a/packages/discord/src/conversation/services/outbox.ts b/packages/discord/src/conversation/outbox.ts similarity index 56% rename from packages/discord/src/conversation/services/outbox.ts rename to packages/discord/src/conversation/outbox.ts index 51a4c096fb..b3c32f8793 100644 --- a/packages/discord/src/conversation/services/outbox.ts +++ b/packages/discord/src/conversation/outbox.ts @@ -1,12 +1,12 @@ import { Context, Effect, Layer } from "effect" -import type { ThreadId } from "../../types" -import type { DeliveryError } from "../model/errors" -import type { Action } from "../model/schema" +import type { ThreadId } from "../types" +import type { DeliveryError } from "./model/errors" +import type { Action } from "./model/schema" export declare namespace Outbox { export interface Service { readonly publish: (action: Action) => Effect.Effect - readonly withTyping: (thread_id: ThreadId, self: Effect.Effect) => Effect.Effect + readonly withTyping: (threadId: ThreadId, self: Effect.Effect) => Effect.Effect } } @@ -15,7 +15,7 @@ export class Outbox extends Context.Tag("@discord/conversation/Outbox") Effect.void, - withTyping: (_thread_id, self) => self, + withTyping: (_threadId, self) => self, }), ) } diff --git a/packages/discord/src/conversation/services/conversation.ts b/packages/discord/src/conversation/services/conversation.ts deleted file mode 100644 index b635d47066..0000000000 --- a/packages/discord/src/conversation/services/conversation.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { Context, Effect, Layer, Option, Schedule, Stream } from "effect" -import { AppConfig } from "../../config" -import { TurnRouter } from "../../discord/turn-routing" -import { ActorMap } from "../../lib/actors/keyed" -import { ThreadAgentPool } from "../../sandbox/pool" -import type { ChannelId, ThreadId } from "../../types" -import { type ConversationError, messageOf, RoutingError, SandboxSendError } from "../model/errors" -import { Send, type Inbound } from "../model/schema" -import { History } from "./history" -import { Inbox } from "./inbox" -import { ConversationLedger } from "./ledger" -import { Outbox } from "./outbox" -import { Threads } from "./threads" - -export declare namespace Conversation { - export interface Service { - readonly turn: (event: Inbound) => Effect.Effect - readonly run: Effect.Effect - } -} - -export class Conversation extends Context.Tag("@discord/conversation/Conversation")< - Conversation, - Conversation.Service ->() { - static readonly layer = Layer.scoped( - Conversation, - Effect.gen(function* () { - const inbox = yield* Inbox - const outbox = yield* Outbox - const history = yield* History - const threads = yield* Threads - const ledger = yield* ConversationLedger - const config = yield* AppConfig - const pool = yield* ThreadAgentPool - const router = yield* TurnRouter - const actors = yield* ActorMap.make() - const RETRIABLE_TAGS: ReadonlySet = new Set([ - "SandboxDeadError", - "OpenCodeClientError", - "HealthCheckError", - "SandboxStartError", - ]) - const RETRY_MESSAGE = "Something went wrong. Please try again in a moment." - const turnRetry = Schedule.exponential("500 millis").pipe( - Schedule.intersect(Schedule.recurs(2)), - Schedule.whileInput((error: ConversationError) => error.retriable), - ) - - const asSendError = - (thread_id: ThreadId) => - (cause: { readonly _tag: string }): SandboxSendError => - SandboxSendError.make({ - thread_id, - message: messageOf(cause), - retriable: RETRIABLE_TAGS.has(cause._tag), - }) - - const publishText = (threadId: ThreadId, text: string) => - outbox.publish( - Send.make({ - kind: "send", - thread_id: threadId, - text, - }), - ) - - const reportFailure = (thread_id: ThreadId) => (error: ConversationError) => { - if (error.retriable) return Effect.fail(error) - return publishText(thread_id, RETRY_MESSAGE).pipe( - Effect.catchAll(() => Effect.void), - Effect.zipRight(Effect.fail(error)), - ) - } - - const route = Effect.fn("Conversation.route")(function* (event: Inbound) { - if (event.author_is_bot) return false - if (event.mentions_everyone) return false - if (!event.content.trim()) return false - - const mentioned = - event.mentions.user_ids.includes(event.bot_user_id) || - (event.bot_role_id.length > 0 && event.mentions.role_ids.includes(event.bot_role_id)) - if (event.kind === "channel_message") return mentioned - if (mentioned) return true - - const owned = yield* pool.hasTrackedThread(event.thread_id).pipe(Effect.mapError(asSendError(event.thread_id))) - if (!owned) return false - - const decision = yield* router - .shouldRespond({ - content: event.content, - botUserId: event.bot_user_id, - botRoleId: event.bot_role_id, - mentionedUserIds: event.mentions.user_ids, - mentionedRoleIds: event.mentions.role_ids, - }) - .pipe( - Effect.mapError((cause) => - RoutingError.make({ - message: messageOf(cause), - retriable: false, - }), - ), - ) - return decision.shouldRespond - }) - - const resolve = Effect.fn("Conversation.resolve")(function* (event: Inbound) { - if (event.kind === "thread_message") { - return { thread_id: event.thread_id, channel_id: event.channel_id } - } - const name = yield* router.generateThreadName(event.content) - return yield* threads.ensure(event, name) - }) - - const buildInput = Effect.fn("Conversation.buildInput")(function* ( - event: Inbound, - target: { thread_id: ThreadId; channel_id: ChannelId }, - ) { - const toSendError = asSendError(target.thread_id) - const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(Effect.mapError(toSendError)) - const agent = yield* pool - .getOrCreate(target.thread_id, target.channel_id, event.guild_id) - .pipe(Effect.mapError(toSendError)) - const current = yield* agent.current().pipe(Effect.mapError(toSendError)) - const prompt = - Option.isSome(tracked) && tracked.value.sessionId !== current.sessionId - ? yield* history.rehydrate(target.thread_id, event.content) - : event.content - return { target, agent, prompt, session: current } - }) - - const command = (event: Inbound, target: { thread_id: ThreadId; channel_id: ChannelId }) => - Effect.gen(function* () { - const text = event.content.trim().toLowerCase() - if (text === "!reset") { - yield* pool.destroySession(target.thread_id).pipe(Effect.catchAll(() => Effect.void)) - yield* publishText(target.thread_id, "*☠️ Session destroyed. Next message will provision a fresh sandbox.*") - return true - } - if (text === "!status") { - const tracked = yield* pool - .getTrackedSession(target.thread_id) - .pipe(Effect.catchAll(() => Effect.succeed(Option.none()))) - if (Option.isNone(tracked)) { - yield* publishText(target.thread_id, "*No active session for this thread.*") - } else { - const s = tracked.value - const model = config.openCodeModel.replace("opencode/", "") - const lines = [ - `**Status:** ${s.status}`, - `**Model:** \`${model}\``, - `**Sandbox:** \`${s.sandboxId}\``, - `**Session:** \`${s.sessionId}\``, - s.resumeFailCount > 0 ? `**Resume failures:** ${s.resumeFailCount}` : null, - s.lastError ? `**Last error:** ${s.lastError.slice(0, 200)}` : null, - ].filter(Boolean) - yield* publishText(target.thread_id, lines.join("\n")) - } - return true - } - return false - }) - - const turnRaw = Effect.fn("Conversation.turnRaw")(function* (event: Inbound) { - if (!(yield* route(event))) return - - const target = yield* resolve(event) - - if (yield* command(event, target)) return - - yield* Effect.logInfo("User message").pipe( - Effect.annotateLogs({ - event: "conversation.user.message", - thread_id: target.thread_id, - author_id: event.author_id, - content: event.content.slice(0, 200), - }), - ) - - yield* outbox - .withTyping( - target.thread_id, - Effect.gen(function* () { - const input = yield* buildInput(event, target) - - const reply = yield* input.agent.send(input.prompt).pipe( - Effect.catchTag("SandboxDeadError", () => - Effect.gen(function* () { - yield* publishText(input.target.thread_id, "*Session changed state, recovering...*") - const toErr = asSendError(input.target.thread_id) - const next = yield* pool - .getOrCreate(input.target.thread_id, input.target.channel_id, event.guild_id) - .pipe(Effect.mapError(toErr)) - const nextSession = yield* next.current().pipe(Effect.mapError(toErr)) - const prompt = - nextSession.sessionId !== input.session.sessionId - ? yield* history.rehydrate(input.target.thread_id, event.content) - : event.content - return yield* next.send(prompt) - }), - ), - Effect.mapError(asSendError(input.target.thread_id)), - ) - - yield* Effect.logInfo("Bot reply").pipe( - Effect.annotateLogs({ - event: "conversation.bot.reply", - thread_id: input.target.thread_id, - content: reply.slice(0, 200), - }), - ) - yield* publishText(input.target.thread_id, reply) - }), - ) - .pipe(Effect.catchAll(reportFailure(target.thread_id))) - }) - - const keyOf = (event: Inbound) => - event.kind === "thread_message" ? `thread:${event.thread_id}` : `channel:${event.channel_id}` - - const processEvent = (event: Inbound) => actors.run(keyOf(event), turnRaw(event), { touch: false }) - - const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) { - const fresh = yield* ledger.dedup(event.message_id) - if (!fresh) return - yield* processEvent(event) - }) - - const run = inbox.events.pipe( - Stream.mapEffect( - (event) => - ledger.dedup(event.message_id).pipe( - Effect.flatMap((fresh) => { - if (!fresh) return Effect.void - return processEvent(event).pipe( - Effect.retry(turnRetry), - Effect.catchAll((error) => - Effect.logError("Conversation turn failed").pipe( - Effect.annotateLogs({ - event: "conversation.turn.failed", - tag: error._tag, - retriable: error.retriable, - message: error.message, - }), - ), - ), - ) - }), - ), - { concurrency: "unbounded", unordered: true }, - ), - Stream.runDrain, - ) - - return Conversation.of({ turn, run }) - }), - ) -} diff --git a/packages/discord/src/conversation/services/ledger.test.ts b/packages/discord/src/conversation/services/ledger.test.ts deleted file mode 100644 index be029f3fdd..0000000000 --- a/packages/discord/src/conversation/services/ledger.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect } from "bun:test" -import { Duration, Effect, Layer, Option, Redacted } from "effect" -import { AppConfig } from "../../config" -import { SqliteDb } from "../../db/client" -import { effectTest, withTempSqliteFile } from "../../test/effect" -import { ConversationLedger } from "./ledger" - -const makeConfig = (databasePath: string) => - AppConfig.of({ - discordToken: Redacted.make("token"), - allowedChannelIds: [], - discordCategoryId: "", - discordRoleId: "", - discordRequiredRoleId: "", - discordCommandGuildId: "", - databasePath, - daytonaApiKey: Redacted.make("daytona"), - openCodeZenApiKey: Redacted.make("zen"), - githubToken: "", - logLevel: "info", - healthHost: "127.0.0.1", - healthPort: 8787, - turnRoutingMode: "off", - turnRoutingModel: "claude-haiku-4-5", - sandboxReusePolicy: "resume_preferred", - sandboxTimeout: Duration.minutes(30), - cleanupInterval: Duration.minutes(5), - staleActiveGraceMinutes: 5 as AppConfig.Service["staleActiveGraceMinutes"], - pausedTtlMinutes: 180 as AppConfig.Service["pausedTtlMinutes"], - activeHealthCheckTimeoutMs: 15000 as AppConfig.Service["activeHealthCheckTimeoutMs"], - startupHealthTimeoutMs: 120000 as AppConfig.Service["startupHealthTimeoutMs"], - resumeHealthTimeoutMs: 120000 as AppConfig.Service["resumeHealthTimeoutMs"], - sandboxCreationTimeout: 180 as AppConfig.Service["sandboxCreationTimeout"], - openCodeModel: "opencode/claude-sonnet-4-5", - }) - -const withLedger = ( - run: (ledger: ConversationLedger.Service) => Effect.Effect, -) => - withTempSqliteFile((databasePath) => - Effect.gen(function* () { - const config = Layer.succeed(AppConfig, makeConfig(databasePath)) - const sqlite = SqliteDb.layer.pipe(Layer.provide(config)) - const deps = Layer.merge(sqlite, config) - const live = ConversationLedger.layer.pipe(Layer.provide(deps)) - const program = Effect.flatMap(ConversationLedger, (ledger) => run(ledger)) - return yield* program.pipe(Effect.provide(live)) - }), - "discord-ledger-", - ) - -describe("ConversationLedger", () => { - effectTest("dedup returns true first time, false second time", () => - Effect.gen(function* () { - const ledger = yield* ConversationLedger - expect(yield* ledger.dedup("m1")).toBe(true) - expect(yield* ledger.dedup("m1")).toBe(false) - expect(yield* ledger.dedup("m2")).toBe(true) - expect(yield* ledger.dedup("m2")).toBe(false) - }).pipe(Effect.provide(ConversationLedger.noop)), - ) - - effectTest("stores and updates source offsets", () => - withLedger((ledger) => - Effect.gen(function* () { - expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true) - yield* ledger.setOffset("thread:t1", "m1") - expect(yield* ledger.getOffset("thread:t1")).toEqual(Option.some("m1")) - yield* ledger.setOffset("thread:t1", "m9") - expect(yield* ledger.getOffset("thread:t1")).toEqual(Option.some("m9")) - }), - ), - ) - - effectTest("dedup works in layer mode", () => - withLedger((ledger) => - Effect.gen(function* () { - expect(yield* ledger.dedup("m1")).toBe(true) - expect(yield* ledger.dedup("m1")).toBe(false) - expect(yield* ledger.dedup("m2")).toBe(true) - }), - ), - ) - - effectTest("noop offsets always return none", () => - Effect.gen(function* () { - const ledger = yield* ConversationLedger - expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true) - yield* ledger.setOffset("thread:t1", "m1") - expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true) - }).pipe(Effect.provide(ConversationLedger.noop)), - ) -}) diff --git a/packages/discord/src/conversation/services/ledger.ts b/packages/discord/src/conversation/services/ledger.ts deleted file mode 100644 index 603b302db8..0000000000 --- a/packages/discord/src/conversation/services/ledger.ts +++ /dev/null @@ -1,85 +0,0 @@ -import * as Client from "@effect/sql/SqlClient" -import { Context, Effect, Layer, Option } from "effect" -import { SqliteDb } from "../../db/client" -import { initializeSchema } from "../../db/init" -import { DatabaseError } from "../../errors" - -const DEDUP_LIMIT = 4_000 - -const db = (effect: Effect.Effect) => - effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause }))) - -const makeDedupSet = () => { - const seen = new Set() - const order: Array = [] - return (message_id: string): boolean => { - if (seen.has(message_id)) return false - seen.add(message_id) - order.push(message_id) - if (order.length > DEDUP_LIMIT) { - const oldest = order.shift() - if (oldest) seen.delete(oldest) - } - return true - } -} - -export declare namespace ConversationLedger { - export interface Service { - readonly dedup: (message_id: string) => Effect.Effect - readonly getOffset: (source_id: string) => Effect.Effect, DatabaseError> - readonly setOffset: (source_id: string, message_id: string) => Effect.Effect - } -} - -export class ConversationLedger extends Context.Tag("@discord/conversation/ConversationLedger")< - ConversationLedger, - ConversationLedger.Service ->() { - static readonly noop = Layer.sync(ConversationLedger, () => { - const check = makeDedupSet() - return ConversationLedger.of({ - dedup: (message_id) => Effect.sync(() => check(message_id)), - getOffset: () => Effect.succeed(Option.none()), - setOffset: () => Effect.void, - }) - }) - - static readonly layer = Layer.effect( - ConversationLedger, - Effect.gen(function* () { - const sql = yield* SqliteDb - yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql))) - - const check = makeDedupSet() - - const getOffset = Effect.fn("ConversationLedger.getOffset")(function* (source_id: string) { - const rows = yield* db( - sql<{ last_message_id: string }>`SELECT last_message_id - FROM conversation_offsets - WHERE source_id = ${source_id} - LIMIT 1`, - ) - const row = rows[0] - if (!row) return Option.none() - return Option.some(row.last_message_id) - }) - - const setOffset = Effect.fn("ConversationLedger.setOffset")(function* (source_id: string, message_id: string) { - yield* db( - sql`INSERT INTO conversation_offsets (source_id, last_message_id, updated_at) - VALUES (${source_id}, ${message_id}, CURRENT_TIMESTAMP) - ON CONFLICT(source_id) DO UPDATE SET - last_message_id = excluded.last_message_id, - updated_at = CURRENT_TIMESTAMP`, - ) - }) - - return ConversationLedger.of({ - dedup: (message_id) => Effect.sync(() => check(message_id)), - getOffset, - setOffset, - }) - }), - ) -} diff --git a/packages/discord/src/conversation/thread/cluster/chat-live.ts b/packages/discord/src/conversation/thread/cluster/chat-live.ts new file mode 100644 index 0000000000..b5da83ab09 --- /dev/null +++ b/packages/discord/src/conversation/thread/cluster/chat-live.ts @@ -0,0 +1,61 @@ +import { Effect, Layer, Option } from "effect" +import { DatabaseError } from "../../../errors" +import type { ThreadId } from "../../../types" +import { SendInput, ThreadChatCluster, ThreadChatError } from "./contracts" +import { ThreadEntity } from "./entity" + +const asThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => { + if (cause instanceof ThreadChatError) return cause + return ThreadChatError.make({ + threadId, + cause, + retriable: false, + }) +} + +const asDatabaseError = (cause: unknown): DatabaseError => { + if (cause instanceof DatabaseError) return cause + return DatabaseError.make({ cause }) +} + +export const ThreadChatClusterLive = Layer.effect( + ThreadChatCluster, + Effect.gen(function* () { + const make = yield* ThreadEntity.client + + const send = Effect.fn("ThreadChatCluster.send")(function* (input) { + const rpc = make(input.threadId) + const out = yield* rpc + .send( + SendInput.make({ + channelId: input.channelId, + guildId: input.guildId, + messageId: input.messageId, + text: input.text, + }), + ) + .pipe( + Effect.mapError((cause) => asThreadError(input.threadId, cause)), + ) + return { + text: out.text, + session: out.session, + changedSession: out.changedSession, + } + }) + + const status = Effect.fn("ThreadChatCluster.status")(function* (threadId) { + const rpc = make(threadId) + const row = yield* rpc.status(undefined).pipe(Effect.mapError(asDatabaseError)) + if (row === null) return Option.none() + return Option.some(row) + }) + + const recreate = Effect.fn("ThreadChatCluster.recreate")(function* (threadId) { + const rpc = make(threadId) + yield* rpc.recreate(undefined).pipe(Effect.mapError(asDatabaseError)) + }) + + return ThreadChatCluster.of({ send, status, recreate }) + }), +) diff --git a/packages/discord/src/conversation/thread/cluster/contracts.ts b/packages/discord/src/conversation/thread/cluster/contracts.ts new file mode 100644 index 0000000000..ede2f47627 --- /dev/null +++ b/packages/discord/src/conversation/thread/cluster/contracts.ts @@ -0,0 +1,97 @@ +import { Context, Effect, Option, PrimaryKey, Schema } from "effect" +import { DatabaseError } from "../../../errors" +import { ChannelId, GuildId, SandboxId, SessionInfo, ThreadId } from "../../../types" + +export class ThreadChatError extends Schema.TaggedError()( + "ThreadChatError", + { + threadId: ThreadId, + cause: Schema.Defect, + retriable: Schema.Boolean, + }, +) {} + +export class SendInput extends Schema.Class("ClusterMode/SendInput")({ + channelId: ChannelId, + guildId: GuildId, + messageId: Schema.String, + text: Schema.String, +}) { + [PrimaryKey.symbol]() { + return this.messageId + } +} + +export class SendOutput extends Schema.Class("ClusterMode/SendOutput")({ + text: Schema.String, + session: SessionInfo, + changedSession: Schema.Boolean, +}) {} + +export class PauseInput extends Schema.Class("ClusterMode/PauseInput")({ + reason: Schema.String, +}) {} + +export class ResumeInput extends Schema.Class("ClusterMode/ResumeInput")({ + channelId: Schema.NullOr(ChannelId), + guildId: Schema.NullOr(GuildId), +}) {} + +export class LogsInput extends Schema.Class("ClusterMode/LogsInput")({ + lines: Schema.Number.pipe( + Schema.int(), + Schema.between(1, 500), + ), +}) {} + +export class LogsOutput extends Schema.Class("ClusterMode/LogsOutput")({ + sandboxId: SandboxId, + output: Schema.String, +}) {} + +export declare namespace ThreadChatCluster { + export interface Service { + readonly send: (input: { + threadId: ThreadId + channelId: ChannelId + guildId: GuildId + messageId: string + text: string + }) => Effect.Effect<{ + text: string + session: SessionInfo + changedSession: boolean + }, ThreadChatError> + readonly status: (threadId: ThreadId) => Effect.Effect, DatabaseError> + readonly recreate: (threadId: ThreadId) => Effect.Effect + } +} + +export class ThreadChatCluster extends Context.Tag("@discord/conversation/thread/cluster/ThreadChatCluster")< + ThreadChatCluster, + ThreadChatCluster.Service +>() {} + +export declare namespace ThreadControlCluster { + export interface Service { + readonly active: Effect.Effect, DatabaseError> + readonly pause: (input: { + threadId: ThreadId + reason: string + }) => Effect.Effect, ThreadChatError> + readonly resume: (input: { + threadId: ThreadId + channelId: ChannelId | null + guildId: GuildId | null + }) => Effect.Effect + readonly logs: (input: { + threadId: ThreadId + lines: number + }) => Effect.Effect, ThreadChatError> + } +} + +export class ThreadControlCluster extends Context.Tag("@discord/conversation/thread/cluster/ThreadControlCluster")< + ThreadControlCluster, + ThreadControlCluster.Service +>() {} diff --git a/packages/discord/src/conversation/thread/cluster/control-live.ts b/packages/discord/src/conversation/thread/cluster/control-live.ts new file mode 100644 index 0000000000..3af69d52b3 --- /dev/null +++ b/packages/discord/src/conversation/thread/cluster/control-live.ts @@ -0,0 +1,65 @@ +import { Effect, Layer, Option } from "effect" +import { DatabaseError } from "../../../errors" +import { SessionStore } from "../../../session/store" +import type { ThreadId } from "../../../types" +import { LogsInput, PauseInput, ResumeInput, ThreadChatError, ThreadControlCluster } from "./contracts" +import { ThreadEntity } from "./entity" + +const asThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => { + if (cause instanceof ThreadChatError) return cause + return ThreadChatError.make({ + threadId, + cause, + retriable: false, + }) +} + +const asDatabaseError = (cause: unknown): DatabaseError => { + if (cause instanceof DatabaseError) return cause + return DatabaseError.make({ cause }) +} + +export const ThreadControlClusterLive = Layer.effect( + ThreadControlCluster, + Effect.gen(function* () { + const make = yield* ThreadEntity.client + const store = yield* SessionStore + + const active = store.listActive().pipe(Effect.mapError(asDatabaseError)) + + const pause = Effect.fn("ThreadControlCluster.pause")(function* (input) { + const rpc = make(input.threadId) + const row = yield* rpc.pause(PauseInput.make({ reason: input.reason })).pipe( + Effect.mapError((cause) => asThreadError(input.threadId, cause)), + ) + if (row === null) return Option.none() + return Option.some(row) + }) + + const resume = Effect.fn("ThreadControlCluster.resume")(function* (input) { + const rpc = make(input.threadId) + return yield* rpc.resume( + ResumeInput.make({ + channelId: input.channelId, + guildId: input.guildId, + }), + ).pipe( + Effect.mapError((cause) => asThreadError(input.threadId, cause)), + ) + }) + + const logs = Effect.fn("ThreadControlCluster.logs")(function* (input) { + const rpc = make(input.threadId) + const row = yield* rpc.logs(LogsInput.make({ lines: input.lines })).pipe( + Effect.mapError((cause) => asThreadError(input.threadId, cause)), + ) + if (row === null) return Option.none() + return Option.some({ + sandboxId: row.sandboxId, + output: row.output, + }) + }) + + return ThreadControlCluster.of({ active, pause, resume, logs }) + }), +) diff --git a/packages/discord/src/conversation/thread/cluster/entity.test.ts b/packages/discord/src/conversation/thread/cluster/entity.test.ts new file mode 100644 index 0000000000..fb381194d7 --- /dev/null +++ b/packages/discord/src/conversation/thread/cluster/entity.test.ts @@ -0,0 +1,150 @@ +import { describe, expect } from "bun:test" +import { TestRunner } from "@effect/cluster" +import { Effect, Layer, Option } from "effect" +import { History } from "../../history" +import { ThreadChatCluster, ThreadChatClusterLive, ThreadEntityLive } from "." +import { OpenCodeClient, OpenCodeSessionSummary } from "../../../sandbox/opencode/client" +import { SandboxProvisioner } from "../../../sandbox/provisioner" +import { SessionStore } from "../../../session/store" +import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionId, SessionInfo, ThreadId } from "../../../types" +import { DaytonaService } from "../../../sandbox/daytona/service" +import { OpenCodeClientError } from "../../../errors" +import { SqliteDb } from "../../../db/client" +import { effectTest, testConfigLayer } from "../../../test/effect" + +const makeSession = (input: { threadId: ThreadId; channelId: ChannelId; guildId: GuildId; sandboxId: string; sessionId: string }) => + SessionInfo.make({ + threadId: input.threadId, + channelId: input.channelId, + guildId: input.guildId, + sandboxId: SandboxId.make(input.sandboxId), + sessionId: SessionId.make(input.sessionId), + previewUrl: "https://preview", + previewToken: null, + status: "active", + lastError: null, + resumeFailCount: 0, + }) + +describe("ThreadEntity", () => { + effectTest("retries after sandbox-down by re-ensuring session and rehydrating prompt", () => { + const threadId = ThreadId.make("t-dead") + const channelId = ChannelId.make("c-dead") + const guildId = GuildId.make("g-dead") + const first = makeSession({ threadId, channelId, guildId, sandboxId: "sb-1", sessionId: "s-1" }) + const second = makeSession({ threadId, channelId, guildId, sandboxId: "sb-2", sessionId: "s-2" }) + + const ensured: Array = [] + const recovered: Array = [] + const sent: Array = [] + const rehydrated: Array = [] + + const historyLayer = Layer.succeed( + History, + History.of({ + rehydrate: (_threadId, latest) => + Effect.sync(() => { + rehydrated.push(latest) + return `rehydrated:${latest}` + }), + }), + ) + + const provisionerLayer = Layer.succeed( + SandboxProvisioner, + SandboxProvisioner.of({ + provision: () => Effect.dieMessage("unused"), + resume: () => Effect.dieMessage("unused"), + ensureActive: (input) => + Effect.sync(() => { + ensured.push(Option.isSome(input.current) ? String(input.current.value.sessionId) : "none") + return ensured.length === 1 ? first : second + }), + ensureHealthy: () => Effect.succeed(true), + recoverSendFailure: (_threadId, session) => + Effect.sync(() => { + recovered.push(String(session.sessionId)) + return SessionInfo.make({ + ...session, + status: "error", + lastError: "send-failed", + }) + }), + pause: (_threadId, session) => Effect.succeed(session), + destroy: (_threadId, session) => Effect.succeed(session), + }), + ) + + const openCodeLayer = Layer.succeed( + OpenCodeClient, + OpenCodeClient.of({ + waitForHealthy: () => Effect.succeed(true), + createSession: () => Effect.succeed(SessionId.make("unused")), + sessionExists: () => Effect.succeed(true), + listSessions: () => Effect.succeed([] as ReadonlyArray), + sendPrompt: (_preview, _sessionId, text) => + Effect.gen(function* () { + sent.push(text) + if (sent.length === 1) { + return yield* OpenCodeClientError.make({ + operation: "sendPrompt", + statusCode: 502, + body: "bad gateway", + kind: "sandbox-down", + }) + } + return `ok:${text}` + }), + abortSession: () => Effect.void, + }), + ) + + const daytonaLayer = Layer.succeed( + DaytonaService, + DaytonaService.of({ + create: () => Effect.dieMessage("unused"), + exec: () => Effect.dieMessage("unused"), + start: () => Effect.dieMessage("unused"), + stop: () => Effect.dieMessage("unused"), + destroy: () => Effect.void, + getPreview: () => + Effect.succeed( + PreviewAccess.make({ + previewUrl: "https://preview", + previewToken: null, + }), + ), + }), + ) + + const live = ThreadChatClusterLive.pipe( + Layer.provideMerge(ThreadEntityLive), + Layer.provideMerge(TestRunner.layer), + Layer.provideMerge(SessionStore.layer), + Layer.provideMerge(SqliteDb.layer), + Layer.provideMerge(testConfigLayer), + Layer.provideMerge(daytonaLayer), + Layer.provideMerge(openCodeLayer), + Layer.provideMerge(provisionerLayer), + Layer.provideMerge(historyLayer), + ) + + return Effect.gen(function* () { + const threadChat = yield* ThreadChatCluster + const out = yield* threadChat.send({ + threadId, + channelId, + guildId, + messageId: "m-1", + text: "hello", + }) + + expect(ensured).toEqual(["none", "s-1"]) + expect(recovered).toEqual(["s-1"]) + expect(rehydrated).toEqual(["hello"]) + expect(sent).toEqual(["hello", "rehydrated:hello"]) + expect(out.text).toBe("ok:rehydrated:hello") + expect(out.session.sessionId).toBe(SessionId.make("s-2")) + }).pipe(Effect.provide(live)) + }) +}) diff --git a/packages/discord/src/conversation/thread/cluster/entity.ts b/packages/discord/src/conversation/thread/cluster/entity.ts new file mode 100644 index 0000000000..42c6342872 --- /dev/null +++ b/packages/discord/src/conversation/thread/cluster/entity.ts @@ -0,0 +1,315 @@ +import { ClusterSchema, Entity } from "@effect/cluster" +import { Rpc } from "@effect/rpc" +import { Effect, Option, Predicate, Schema } from "effect" +import { + DatabaseError, + type HealthCheckError, + isOpenCodeSandboxUnavailable, + OpenCodeClientError, + SandboxDeadError, + type SandboxStartError, +} from "../../../errors" +import { StatefulActor } from "../../../lib/actors/stateful" +import { DaytonaService } from "../../../sandbox/daytona/service" +import { OpenCodeClient } from "../../../sandbox/opencode/client" +import { SandboxProvisioner } from "../../../sandbox/provisioner" +import { SessionStore } from "../../../session/store" +import { ChannelId, GuildId, PreviewAccess, SessionInfo, ThreadId } from "../../../types" +import { History } from "../../history" +import { + LogsInput, + LogsOutput, + PauseInput, + ResumeInput, + SendInput, + SendOutput, + ThreadChatError, +} from "./contracts" + +const StatusInput = Schema.Void +const RecreateInput = Schema.Void + +type RetriableCause = SandboxDeadError | OpenCodeClientError | HealthCheckError | SandboxStartError + +const isRetriableCause = (cause: unknown): cause is RetriableCause => + Predicate.isTagged(cause, "SandboxDeadError") || + Predicate.isTagged(cause, "OpenCodeClientError") || + Predicate.isTagged(cause, "HealthCheckError") || + Predicate.isTagged(cause, "SandboxStartError") + +const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => + ThreadChatError.make({ + threadId, + cause, + retriable: isRetriableCause(cause), + }) + +class ThreadState extends Schema.Class("ClusterMode/ThreadState")({ + loaded: Schema.Boolean, + session: Schema.NullOr(SessionInfo), +}) { + static empty() { + return ThreadState.make({ loaded: false, session: null }) + } + + option() { + return Option.fromNullable(this.session) + } + + hydrate(row: Option.Option) { + if (Option.isNone(row)) return ThreadState.make({ loaded: true, session: null }) + return ThreadState.make({ loaded: true, session: row.value }) + } + + with(session: SessionInfo) { + return ThreadState.make({ loaded: true, session }) + } + + clear() { + return ThreadState.make({ loaded: true, session: null }) + } +} + +const SendRpc = Rpc.make("send", { + payload: SendInput, + success: SendOutput, + error: ThreadChatError, +}).annotate(ClusterSchema.Persisted, true) + +const StatusRpc = Rpc.make("status", { + payload: StatusInput, + success: Schema.NullOr(SessionInfo), + error: DatabaseError, +}) + +const RecreateRpc = Rpc.make("recreate", { + payload: RecreateInput, + success: Schema.Void, + error: DatabaseError, +}) + +const PauseRpc = Rpc.make("pause", { + payload: PauseInput, + success: Schema.NullOr(SessionInfo), + error: ThreadChatError, +}) + +const ResumeRpc = Rpc.make("resume", { + payload: ResumeInput, + success: SessionInfo, + error: ThreadChatError, +}) + +const LogsRpc = Rpc.make("logs", { + payload: LogsInput, + success: Schema.NullOr(LogsOutput), + error: ThreadChatError, +}) + +export const ThreadEntity = Entity.make("ThreadChat", [ + SendRpc, + StatusRpc, + RecreateRpc, + PauseRpc, + ResumeRpc, + LogsRpc, +]) + +export const ThreadEntityLive = ThreadEntity.toLayer( + Effect.gen(function* () { + const oc = yield* OpenCodeClient + const daytona = yield* DaytonaService + const store = yield* SessionStore + const history = yield* History + const provisioner = yield* SandboxProvisioner + const entityId = String((yield* Entity.CurrentAddress).entityId) + if (entityId.includes("/")) { + return yield* Effect.dieMessage(`ThreadEntity expected raw threadId entityId, got "${entityId}"`) + } + const threadId = ThreadId.make(entityId) + const thread = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => toThreadError(threadId, cause))) + + const state = StatefulActor.make(ThreadState.empty()) + const stateNow = () => state.get().option() + const setState = (session: SessionInfo) => { + state.update((current) => current.with(session)) + } + const clearState = () => { + state.update((current) => current.clear()) + } + + /** Bootstrap state from DB once, then serve from in-memory state. */ + const load = Effect.fn("ThreadEntity.load")(function* () { + const current = state.get() + if (current.loaded) return current.option() + const row = yield* store.getByThread(threadId) + const hydrated = current.hydrate(row) + state.set(hydrated) + return hydrated.option() + }) + + /** Persist the latest session snapshot for crash recovery. */ + const saveSession = Effect.fnUntraced(function* (session: SessionInfo) { + yield* store.upsert(session) + }) + + /** Update in-memory state and persist it as one operation. */ + const commitSession = Effect.fnUntraced(function* (session: SessionInfo) { + setState(session) + yield* saveSession(session) + }) + + /** Ensure there is an active session for this thread (resume or create). */ + const ensureSession = Effect.fnUntraced(function* ( + channelId: ChannelId, + guildId: GuildId, + current: Option.Option, + ) { + return yield* provisioner.ensureActive({ + threadId, + channelId, + guildId, + current, + }) + }) + + /** Rebuild prompt context when session changed; otherwise keep prompt as-is. */ + const rehydrate = (latest: string, before: Option.Option, next: SessionInfo) => { + if (Option.isNone(before)) return Effect.succeed(latest) + if (before.value.sessionId === next.sessionId) return Effect.succeed(latest) + return history.rehydrate(threadId, latest) + } + + const recoverFailure = (session: SessionInfo, error: OpenCodeClientError) => + isOpenCodeSandboxUnavailable(error) + ? SandboxDeadError.make({ + threadId: session.threadId, + reason: `OpenCode send failed (${error.statusCode})`, + }) + : error + + const recoverSendError = Effect.fnUntraced(function* (session: SessionInfo, error: OpenCodeClientError) { + const next = yield* provisioner.recoverSendFailure(session.threadId, session, error) + yield* commitSession(next) + return yield* recoverFailure(session, error) + }) + + /** Send prompt to OpenCode and record recovery state if send failed. */ + const sendPrompt = Effect.fnUntraced(function* (session: SessionInfo, text: string) { + yield* store.markActivity(session.threadId) + return yield* oc + .sendPrompt(PreviewAccess.from(session), session.sessionId, text) + .pipe(Effect.catchTag("OpenCodeClientError", (error) => recoverSendError(session, error))) + }) + + /** Retry exactly once after sandbox-dead by re-ensuring and rehydrating. */ + const retrySend = Effect.fnUntraced(function* (payload: SendInput, prior: SessionInfo, text: string) { + return yield* sendPrompt(prior, text).pipe( + Effect.catchTag("SandboxDeadError", () => + Effect.gen(function* () { + const resumed = yield* ensureSession(payload.channelId, payload.guildId, stateNow()) + yield* commitSession(resumed) + const next = yield* rehydrate(payload.text, Option.some(prior), resumed) + return yield* sendPrompt(resumed, next) + }), + ), + ) + }) + + /** Full send lifecycle: load, ensure, persist, rehydrate, send, retry, return output. */ + const sendNow = Effect.fnUntraced(function* (payload: SendInput) { + return yield* Effect.gen(function* () { + const before = yield* load() + const active = yield* ensureSession(payload.channelId, payload.guildId, before) + yield* commitSession(active) + const first = yield* rehydrate(payload.text, before, active) + const text = yield* retrySend(payload, active, first) + const session = Option.getOrElse(stateNow(), () => active) + return SendOutput.make({ + text, + session, + changedSession: Option.isSome(before) ? before.value.sessionId !== session.sessionId : false, + }) + }) + }) + + /** Full send lifecycle: load, ensure, persist, rehydrate, send, retry, return output. */ + const send = Effect.fn("ThreadEntity.send")(function* ({ payload }) { + return yield* thread(sendNow(payload)) + }) + + /** Read current session from actor state (bootstrapping from DB on first access). */ + const status = Effect.fn("ThreadEntity.status")(function* () { + const row = yield* load() + if (Option.isNone(row)) return null + return row.value + }) + + /** Destroy current session resources for this thread and clear actor state. */ + const recreate = Effect.fn("ThreadEntity.recreate")(function* () { + const row = yield* load() + if (Option.isNone(row)) { + clearState() + return + } + const next = yield* provisioner.destroy(threadId, row.value, "cluster-recreate") + yield* store.upsert(next) + clearState() + }) + + /** Pause active session if present and commit paused state. */ + const pause = Effect.fn("ThreadEntity.pause")(function* ({ payload }) { + return yield* thread(Effect.gen(function* () { + const row = yield* load() + if (Option.isNone(row)) return null + const next = yield* provisioner.pause(threadId, row.value, payload.reason) + yield* commitSession(next) + return next + })) + }) + + /** Resume existing session or create one when missing, then commit state. */ + const resume = Effect.fn("ThreadEntity.resume")(function* ({ payload }) { + return yield* thread(Effect.gen(function* () { + const before = yield* load() + const channelId = payload.channelId === null + ? Option.match(before, { + onNone: () => ChannelId.make("ctl"), + onSome: (row) => row.channelId, + }) + : payload.channelId + const guildId = payload.guildId === null + ? Option.match(before, { + onNone: () => GuildId.make("local"), + onSome: (row) => row.guildId, + }) + : payload.guildId + const next = yield* ensureSession(channelId, guildId, before) + yield* commitSession(next) + return next + })) + }) + + /** Read sandbox log tail for active session if present. */ + const logs = Effect.fn("ThreadEntity.logs")(function* ({ payload }) { + return yield* thread(Effect.gen(function* () { + const row = yield* load() + if (Option.isNone(row)) return null + const out = yield* daytona.exec( + row.value.sandboxId, + "read-opencode-log", + `cat /tmp/opencode.log 2>/dev/null | tail -${payload.lines}`, + ) + return LogsOutput.make({ + sandboxId: row.value.sandboxId, + output: out.output, + }) + })) + }) + + const handlers = { send, status, recreate, pause, resume, logs } + return ThreadEntity.of(handlers) + }), + { maxIdleTime: "30 minutes" }, +) diff --git a/packages/discord/src/conversation/thread/cluster/index.ts b/packages/discord/src/conversation/thread/cluster/index.ts new file mode 100644 index 0000000000..04f4ff6b36 --- /dev/null +++ b/packages/discord/src/conversation/thread/cluster/index.ts @@ -0,0 +1,14 @@ +export { + LogsInput, + LogsOutput, + PauseInput, + ResumeInput, + SendInput, + SendOutput, + ThreadChatCluster, + ThreadControlCluster, + ThreadChatError, +} from "./contracts" +export { ThreadEntity, ThreadEntityLive } from "./entity" +export { ThreadChatClusterLive } from "./chat-live" +export { ThreadControlClusterLive } from "./control-live" diff --git a/packages/discord/src/conversation/services/threads.ts b/packages/discord/src/conversation/threads.ts similarity index 73% rename from packages/discord/src/conversation/services/threads.ts rename to packages/discord/src/conversation/threads.ts index 5e37800078..5e972a1012 100644 --- a/packages/discord/src/conversation/services/threads.ts +++ b/packages/discord/src/conversation/threads.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect" -import { ThreadEnsureError } from "../model/errors" -import type { Inbound, ThreadRef } from "../model/schema" +import { ThreadEnsureError } from "./model/errors" +import type { Inbound, ThreadRef } from "./model/schema" export declare namespace Threads { export interface Service { @@ -14,11 +14,11 @@ export class Threads extends Context.Tag("@discord/conversation/Threads") { if (event.kind === "thread_message") { - return Effect.succeed({ thread_id: event.thread_id, channel_id: event.channel_id }) + return Effect.succeed({ threadId: event.threadId, channelId: event.channelId }) } return Effect.fail( ThreadEnsureError.make({ - channel_id: event.channel_id, + channelId: event.channelId, message: "threads adapter missing for channel message", retriable: false, }), diff --git a/packages/discord/src/db/client.ts b/packages/discord/src/db/client.ts index 5ee4527dde..8eda88fe23 100644 --- a/packages/discord/src/db/client.ts +++ b/packages/discord/src/db/client.ts @@ -1,22 +1,21 @@ import { SqliteClient } from "@effect/sql-sqlite-bun" +import { Reactivity } from "@effect/experimental" import * as Client from "@effect/sql/SqlClient" import { Context, Effect, Layer } from "effect" import { AppConfig } from "../config" export class SqliteDb extends Context.Tag("@discord/SqliteDb")() { - static readonly layer = Layer.effect( - SqliteDb, + static readonly layer = Layer.scopedContext( Effect.gen(function* () { - const db = yield* Client.SqlClient + const config = yield* AppConfig + const db = yield* SqliteClient.make({ filename: config.databasePath }).pipe( + Effect.provide(Reactivity.layer), + ) yield* db`PRAGMA busy_timeout = 5000` - return db + return Context.empty().pipe( + Context.add(SqliteDb, db), + Context.add(Client.SqlClient, db), + ) }), - ).pipe( - Layer.provide( - Layer.unwrapEffect( - Effect.map(AppConfig, (config) => SqliteClient.layer({ filename: config.databasePath })), - ), - ), - Layer.orDie, - ) + ).pipe(Layer.orDie) } diff --git a/packages/discord/src/db/init.ts b/packages/discord/src/db/init.ts index 00bf68c10e..19eb430d6e 100644 --- a/packages/discord/src/db/init.ts +++ b/packages/discord/src/db/init.ts @@ -4,12 +4,10 @@ import * as Migrator from "@effect/sql/Migrator" import * as Client from "@effect/sql/SqlClient" import { Effect } from "effect" import migration0001 from "./migrations/0001_discord_sessions" -import migration0002 from "./migrations/0002_conversation_offsets" const run = Migrator.make({})({ loader: Migrator.fromRecord({ "0001_discord_sessions": migration0001, - "0002_conversation_offsets": migration0002, }), }) diff --git a/packages/discord/src/db/migrations/0002_conversation_offsets.ts b/packages/discord/src/db/migrations/0002_conversation_offsets.ts deleted file mode 100644 index 799d24b182..0000000000 --- a/packages/discord/src/db/migrations/0002_conversation_offsets.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as Client from "@effect/sql/SqlClient" -import { Effect } from "effect" - -const TABLE = `CREATE TABLE IF NOT EXISTS conversation_offsets ( - source_id TEXT PRIMARY KEY, - last_message_id TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -)` - -const COLUMNS = [ - ["last_message_id", "TEXT NOT NULL"], - ["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"], -] as const - -const INDEXES = [ - `CREATE INDEX IF NOT EXISTS conversation_offsets_updated_at_idx - ON conversation_offsets (updated_at)`, -] as const - -export default Effect.gen(function* () { - const db = yield* Client.SqlClient - yield* db.unsafe(TABLE) - - const names = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_offsets)`).map((row) => row.name)) - const missing = COLUMNS.filter(([name]) => !names.has(name)) - yield* Effect.forEach(missing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_offsets ADD COLUMN ${name} ${definition}`), { - discard: true, - }) - - yield* Effect.forEach(INDEXES, (index) => db.unsafe(index), { discard: true }) -}) diff --git a/packages/discord/src/conversation/implementations/discord/index.ts b/packages/discord/src/discord/adapter.ts similarity index 54% rename from packages/discord/src/conversation/implementations/discord/index.ts rename to packages/discord/src/discord/adapter.ts index 2eda47fe1f..b3a174e48f 100644 --- a/packages/discord/src/conversation/implementations/discord/index.ts +++ b/packages/discord/src/discord/adapter.ts @@ -1,222 +1,81 @@ -import type { ChatInputCommandInteraction, GuildMember, Interaction, Message, TextChannel, ThreadChannel } from "discord.js" +import type { ChatInputCommandInteraction, GuildMember, Interaction, Message, TextChannel } from "discord.js" import { ChannelType, MessageFlags } from "discord.js" import { Context, Effect, Layer, Option, Queue, Ref, Runtime, Schedule, Stream } from "effect" -import { AppConfig } from "../../../config" -import { DiscordClient } from "../../../discord/client" -import { TYPING_INTERVAL } from "../../../discord/constants" -import { cleanResponse, splitForDiscord } from "../../../discord/format" -import { ThreadAgentPool } from "../../../sandbox/pool" -import { SessionStore } from "../../../sessions/store" -import { ChannelId, GuildId, ThreadId } from "../../../types" -import { DeliveryError, HistoryError, messageOf, ThreadEnsureError } from "../../model/errors" -import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../../model/schema" -import { ConversationLedger, History, Inbox, Outbox, Threads } from "../../services" +import { AppConfig } from "../config" +import { DiscordClient } from "./client" +import { TYPING_INTERVAL } from "./constants" +import { cleanResponse, splitForDiscord } from "./format" +import { SessionStore } from "../session/store" +import { ChannelId, GuildId, ThreadId } from "../types" +import { DeliveryError, HistoryError, messageOf, ThreadEnsureError } from "../conversation/model/errors" +import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../conversation/model/schema" +import { History, Inbox, OffsetStore, Outbox, Threads } from "../conversation" +import { + COMMAND_ACK, + COMMAND_CHANNEL_REPLY, + COMMAND_FORBIDDEN_REPLY, + COMMAND_NOT_THREAD_REPLY, + COMMANDS, + EMPTY_MENTION_REPLY, + SETUP_FAILURE_REPLY, + commandText, +} from "./conversation-commands" +import { + asTextChannel, + asThreadChannel, + type ChatChannel, + hasRequiredRole, + isChannelAllowed, + isMentioned, +} from "./conversation-channels" +import { catchupBenign, deliveryRetriable, deliveryRetry } from "./conversation-delivery" +import { buildHistoryReplayPrompt } from "./conversation-history" +import { catchupFromOffset } from "./catchup" -type ChatChannel = TextChannel | ThreadChannel - -const HISTORY_FETCH_LIMIT = 40 -const HISTORY_LINE_CHAR_LIMIT = 500 -const HISTORY_TOTAL_CHAR_LIMIT = 6000 -const INGRESS_DEDUP_LIMIT = 4_000 -const EMPTY_MENTION_REPLY = "Tag me with a question!" -const SETUP_FAILURE_REPLY = "Something went wrong setting up the thread." -const COMMAND_NOT_THREAD_REPLY = "Use this command inside a Discord thread." -const COMMAND_FORBIDDEN_REPLY = "You don't have the required role for this command." -const COMMAND_CHANNEL_REPLY = "This thread is not allowed for the bot." -const COMMAND_ACK = "Running command in this thread..." +const CACHE_LIMIT = 4_000 const CATCHUP_PAGE_SIZE = 100 -const COMMANDS = [ - { - name: "status", - description: "Show sandbox status for this thread", - }, - { - name: "reset", - description: "Destroy the sandbox session for this thread", - }, -] as const - -const commandText = (name: string): string => { - if (name === "status") return "!status" - if (name === "reset") return "!reset" - return "" -} - -const isChannelAllowed = (channelId: string, categoryId: string | null, config: AppConfig.Service): boolean => { - if (config.allowedChannelIds.length > 0 && config.allowedChannelIds.includes(channelId)) return true - if (config.discordCategoryId && categoryId === config.discordCategoryId) return true - return false -} - -const hasRequiredRole = (member: GuildMember | null, config: AppConfig.Service): boolean => { - if (!config.discordRequiredRoleId) return true - if (!member) return false - return member.roles.cache.has(config.discordRequiredRoleId) -} - -const asThreadChannel = (value: unknown): ThreadChannel | null => { - if (typeof value !== "object" || value === null) return null - const type = (value as { type?: unknown }).type - if (type === ChannelType.PublicThread || type === ChannelType.PrivateThread) return value as ThreadChannel - return null -} - -const asTextChannel = (value: unknown): TextChannel | null => { - if (typeof value !== "object" || value === null) return null - const type = (value as { type?: unknown }).type - if (type === ChannelType.GuildText) return value as TextChannel - return null -} - -const isMentioned = (message: Message, botUserId: string, botRoleId: string): boolean => { - if (botUserId.length > 0 && message.mentions.users.has(botUserId)) return true - if (botRoleId.length > 0 && message.mentions.roles.has(botRoleId)) return true - if (botUserId.length > 0 && message.content.includes(`<@${botUserId}>`)) return true - if (botUserId.length > 0 && message.content.includes(`<@!${botUserId}>`)) return true - if (botRoleId.length > 0 && message.content.includes(`<@&${botRoleId}>`)) return true - return false -} - -const buildHistoryReplayPrompt = Effect.fn("DiscordAdapter.buildHistoryReplayPrompt")( - function* (channel: ChatChannel, latest: string) { - const fetched = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: HISTORY_FETCH_LIMIT })) - const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp) - const lines = ordered - .filter((prior) => !prior.system) - .flatMap((prior) => { - const text = prior.content.replace(/\s+/g, " ").trim() - const files = prior.attachments.size > 0 - ? `[attachments: ${[...prior.attachments.values()].map((att) => att.name ?? "file").join(", ")}]` - : "" - const line = text || files - if (!line) return [] - const value = line.length > HISTORY_LINE_CHAR_LIMIT ? `${line.slice(0, HISTORY_LINE_CHAR_LIMIT)}...` : line - return [`${prior.author.bot ? "assistant" : "user"}: ${value}`] - }) - - const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines - if (prior.length === 0) return latest - - const selected = prior.reduceRight( - (state, candidate) => { - if (state.stop) return state - if (state.total + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && state.list.length > 0) { - return { ...state, stop: true } - } - return { list: [candidate, ...state.list], total: state.total + candidate.length, stop: false } - }, - { list: [] as ReadonlyArray, total: 0, stop: false }, - ).list - - return [ - "Conversation history from this same Discord thread (oldest to newest):", - selected.join("\n"), - "", - "Continue the same conversation and respond to the latest user message:", - latest, - ].join("\n") - }, -) - -const statusOf = (cause: unknown): number | null => { - if (typeof cause !== "object" || cause === null) return null - const status = (cause as { status?: unknown }).status - if (typeof status === "number") return status - const code = (cause as { code?: unknown }).code - if (typeof code === "number") return code - return null -} - -const deliveryRetriable = (cause: unknown): boolean => { - const status = statusOf(cause) - if (status === 429) return true - if (status !== null && status >= 500) return true - return false -} - -const catchupBenign = (cause: unknown): boolean => { - const text = messageOf(cause).toLowerCase() - if (text.includes("missing access")) return true - if (text.includes("missing permissions")) return true - if (text.includes("unknown channel")) return true - if (text.includes("50001")) return true - if (text.includes("50013")) return true - return false -} - -const deliveryRetry = Schedule.exponential("200 millis").pipe( - Schedule.intersect(Schedule.recurs(3)), - Schedule.whileInput((error: DeliveryError) => error.retriable), -) export class DiscordConversationServices { static readonly portLayer = Layer.scopedContext( Effect.gen(function* () { const client = yield* DiscordClient const config = yield* AppConfig - const pool = yield* ThreadAgentPool const sessions = yield* SessionStore - const ledger = yield* ConversationLedger + const offsets = yield* OffsetStore const runtime = yield* Effect.runtime() const input = yield* Queue.unbounded() const chats = new Map() const texts = new Map() const refs = new Map() const roots = new Map() - const seen = new Set() - const order: Array = [] const ref_ids: Array = [] const root_ids: Array = [] - const mark = (message_id: string): boolean => { - if (seen.has(message_id)) return false - seen.add(message_id) - order.push(message_id) - if (order.length <= INGRESS_DEDUP_LIMIT) return true - const oldest = order.shift() - if (!oldest) return true - seen.delete(oldest) - return true - } - const stash = (map: Map, keys: Array, key: string, value: A) => { if (!map.has(key)) keys.push(key) map.set(key, value) - if (keys.length <= INGRESS_DEDUP_LIMIT) return + if (keys.length <= CACHE_LIMIT) return const oldest = keys.shift() if (!oldest) return map.delete(oldest) } - const sourceChannel = (channel_id: string) => `channel:${channel_id}` - const sourceThread = (thread_id: string) => `thread:${thread_id}` + const sourceChannel = (channelId: string) => `channel:${channelId}` + const sourceThread = (threadId: string) => `thread:${threadId}` const uniq = (values: ReadonlyArray): Array => [...new Set(values)] const offer = (event: Inbound, onFresh: Effect.Effect) => - ledger.dedup(event.message_id).pipe( - Effect.flatMap((fresh) => { - if (!fresh) { - return Effect.logDebug("Message deduped (already seen)").pipe( - Effect.annotateLogs({ - event: "conversation.message.deduped", - message_id: event.message_id, - }), - ) - } - return Effect.logInfo("Message queued").pipe( - Effect.annotateLogs({ - event: "conversation.message.queued", - kind: event.kind, - message_id: event.message_id, - author_id: event.author_id, - content: event.content.slice(0, 200), - }), - ).pipe( - Effect.zipRight(onFresh), - Effect.zipRight(input.offer(event)), - Effect.asVoid, - ) + Effect.logInfo("Message queued").pipe( + Effect.annotateLogs({ + event: "conversation.message.queued", + kind: event.kind, + message_id: event.messageId, + author_id: event.authorId, + content: event.content.slice(0, 200), }), + Effect.zipRight(onFresh), + Effect.zipRight(input.offer(event)), + Effect.asVoid, ) const memberOf = (message: Message) => { @@ -238,102 +97,103 @@ export class DiscordConversationServices { if (source === null) return if (message.author.bot || message.mentions.everyone) { - yield* ledger.setOffset(source, message.id) + yield* offsets.setOffset(source, message.id) return } const member = yield* memberOf(message) if (!hasRequiredRole(member, config)) { - yield* ledger.setOffset(source, message.id) + yield* offsets.setOffset(source, message.id) return } - const bot_user_id = client.user?.id ?? "" - const bot_role_id = config.discordRoleId - const mentioned = isMentioned(message, bot_user_id, bot_role_id) + const botUserId = client.user?.id ?? "" + const botRoleId = config.discordRoleId + const mentioned = isMentioned(message, botUserId, botRoleId) const content = message.content.replace(/<@[!&]?\d+>/g, "").trim() const mentions = Mention.make({ - user_ids: [...message.mentions.users.keys()], - role_ids: [...message.mentions.roles.keys()], + userIds: [...message.mentions.users.keys()], + roleIds: [...message.mentions.roles.keys()], }) if (!content && mentioned) { yield* Effect.tryPromise(() => message.reply(EMPTY_MENTION_REPLY)).pipe(Effect.catchAll(() => Effect.void)) - yield* ledger.setOffset(source, message.id) + yield* offsets.setOffset(source, message.id) return } if (message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread) { - const thread = message.channel as ThreadChannel - const thread_id = ThreadId.make(thread.id) - const channel_id = ChannelId.make(thread.parentId ?? thread.id) + const thread = asThreadChannel(message.channel) + if (!thread) return + const threadId = ThreadId.make(thread.id) + const channelId = ChannelId.make(thread.parentId ?? thread.id) const allowed = isChannelAllowed(thread.parentId ?? "", thread.parent?.parentId ?? null, config) if (!allowed) { - const owned = yield* pool.hasTrackedThread(thread_id).pipe( + const owned = yield* sessions.hasTrackedThread(threadId).pipe( Effect.catchAll(() => Effect.succeed(false)), ) if (!owned || mentioned) { - yield* ledger.setOffset(source, message.id) + yield* offsets.setOffset(source, message.id) return } } const event = ThreadMessage.make({ kind: "thread_message", - thread_id, - channel_id, - message_id: message.id, - guild_id: GuildId.make(message.guildId ?? ""), - bot_user_id, - bot_role_id, - author_id: message.author.id, - author_is_bot: message.author.bot, - mentions_everyone: message.mentions.everyone, + threadId, + channelId, + messageId: message.id, + guildId: GuildId.make(message.guildId ?? ""), + botUserId, + botRoleId, + authorId: message.author.id, + authorIsBot: message.author.bot, + mentionsEveryone: message.mentions.everyone, mentions, content, }) yield* offer( event, Effect.sync(() => { - chats.set(event.thread_id, thread) - stash(refs, ref_ids, event.message_id, message) + chats.set(event.threadId, thread) + stash(refs, ref_ids, event.messageId, message) }), ) - yield* ledger.setOffset(source, message.id) + yield* offsets.setOffset(source, message.id) return } - const channel = message.channel as TextChannel + const channel = asTextChannel(message.channel) + if (!channel) return if (!isChannelAllowed(channel.id, channel.parentId ?? null, config)) { - yield* ledger.setOffset(source, message.id) + yield* offsets.setOffset(source, message.id) return } const event = ChannelMessage.make({ kind: "channel_message", - channel_id: ChannelId.make(channel.id), - message_id: message.id, - guild_id: GuildId.make(message.guildId ?? ""), - bot_user_id, - bot_role_id, - author_id: message.author.id, - author_is_bot: message.author.bot, - mentions_everyone: message.mentions.everyone, + channelId: ChannelId.make(channel.id), + messageId: message.id, + guildId: GuildId.make(message.guildId ?? ""), + botUserId, + botRoleId, + authorId: message.author.id, + authorIsBot: message.author.bot, + mentionsEveryone: message.mentions.everyone, mentions, content, }) yield* offer( event, Effect.sync(() => { - texts.set(event.channel_id, channel) - stash(refs, ref_ids, event.message_id, message) + texts.set(event.channelId, channel) + stash(refs, ref_ids, event.messageId, message) }), ) - yield* ledger.setOffset(source, message.id) + yield* offsets.setOffset(source, message.id) }) const onMessage = (message: Message): void => { - if (!mark(message.id)) return const run = ingestMessage(message).pipe( Effect.catchAll((error) => Effect.logError("Failed ingesting Discord message").pipe( @@ -347,38 +207,28 @@ export class DiscordConversationServices { void Runtime.runPromise(runtime)(run) } - const pullAfter = (channel: ChatChannel, after: string): Effect.Effect => - Effect.tryPromise(() => - channel.messages.fetch({ - limit: CATCHUP_PAGE_SIZE, - after, - }) - ).pipe( - Effect.map((page) => [...page.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)), - Effect.flatMap((rows) => { - if (rows.length === 0) return Effect.succeed(0) - const last = rows.at(-1) - if (!last) return Effect.succeed(0) - return Effect.forEach(rows, (row) => ingestMessage(row), { discard: true }).pipe( - Effect.zipRight( - rows.length < CATCHUP_PAGE_SIZE - ? Effect.succeed(rows.length) - : pullAfter(channel, last.id).pipe(Effect.map((tail: number) => rows.length + tail)), - ), - ) - }), - ) - const catchupSource = (source: string, channel: ChatChannel) => - Effect.gen(function* () { - const offset = yield* ledger.getOffset(source) - if (Option.isNone(offset)) { - const page = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: 1 })) - const latest = page.first() - if (latest) yield* ledger.setOffset(source, latest.id) - return 0 - } - return yield* pullAfter(channel, offset.value) + catchupFromOffset({ + source, + pageSize: CATCHUP_PAGE_SIZE, + offsets, + fetchLatest: Effect.tryPromise(() => channel.messages.fetch({ limit: 1 })).pipe( + Effect.map((page) => { + const latest = page.first() + return latest ? Option.some(latest) : Option.none() + }), + ), + fetchAfter: (after) => + Effect.tryPromise(() => + channel.messages.fetch({ + limit: CATCHUP_PAGE_SIZE, + after, + }) + ).pipe( + Effect.map((page) => [...page.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)), + ), + idOf: (message) => message.id, + ingest: (message) => ingestMessage(message), }) const categoryChannels = () => @@ -404,14 +254,14 @@ export class DiscordConversationServices { return nested.flat() }) - const fetchText = (channel_id: string) => - Effect.tryPromise(() => client.channels.fetch(channel_id)).pipe( + const fetchText = (channelId: string) => + Effect.tryPromise(() => client.channels.fetch(channelId)).pipe( Effect.map((channel) => asTextChannel(channel)), Effect.catchAll(() => Effect.succeed(null)), ) - const fetchThread = (thread_id: string) => - Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe( + const fetchThread = (threadId: string) => + Effect.tryPromise(() => client.channels.fetch(threadId)).pipe( Effect.map((channel) => asThreadChannel(channel)), Effect.catchAll(() => Effect.succeed(null)), ) @@ -424,18 +274,18 @@ export class DiscordConversationServices { const fromChannels = yield* Effect.forEach( channels, - (channel_id) => - fetchText(channel_id).pipe( + (channelId) => + fetchText(channelId).pipe( Effect.flatMap((channel) => { if (!channel) return Effect.succeed(0) - return catchupSource(sourceChannel(channel_id), channel) + return catchupSource(sourceChannel(channelId), channel) }), Effect.catchAll((error) => { const log = catchupBenign(error) ? Effect.logDebug("Channel catch-up skipped") : Effect.logWarning("Channel catch-up failed") return log.pipe( Effect.annotateLogs({ event: "conversation.catchup.channel.failed", - channel_id, + channel_id: channelId, error: messageOf(error), }), Effect.as(0), @@ -447,18 +297,18 @@ export class DiscordConversationServices { const fromThreads = yield* Effect.forEach( threads, - (thread_id) => - fetchThread(thread_id).pipe( + (threadId) => + fetchThread(threadId).pipe( Effect.flatMap((thread) => { if (!thread) return Effect.succeed(0) - return catchupSource(sourceThread(thread_id), thread) + return catchupSource(sourceThread(threadId), thread) }), Effect.catchAll((error) => { const log = catchupBenign(error) ? Effect.logDebug("Thread catch-up skipped") : Effect.logWarning("Thread catch-up failed") return log.pipe( Effect.annotateLogs({ event: "conversation.catchup.thread.failed", - thread_id, + thread_id: threadId, error: messageOf(error), }), Effect.as(0), @@ -492,7 +342,6 @@ export class DiscordConversationServices { if (!interaction.isChatInputCommand()) return const text = commandText(interaction.commandName) if (!text) return - if (!mark(interaction.id)) return const handle = Effect.gen(function* () { yield* Effect.tryPromise(() => interaction.deferReply({ @@ -504,11 +353,11 @@ export class DiscordConversationServices { yield* acknowledge(interaction, COMMAND_NOT_THREAD_REPLY) return } - const thread_id = ThreadId.make(thread.id) - const channel_id = ChannelId.make(thread.parentId ?? thread.id) + const threadId = ThreadId.make(thread.id) + const channelId = ChannelId.make(thread.parentId ?? thread.id) const allowed = isChannelAllowed(thread.parentId ?? "", thread.parent?.parentId ?? null, config) if (!allowed) { - const owned = yield* pool.hasTrackedThread(thread_id).pipe( + const owned = yield* sessions.hasTrackedThread(threadId).pipe( Effect.catchAll(() => Effect.succeed(false)), ) if (!owned) { @@ -525,35 +374,28 @@ export class DiscordConversationServices { return } - const bot_user_id = client.user?.id ?? "" + const botUserId = client.user?.id ?? "" const event = ThreadMessage.make({ kind: "thread_message", - thread_id, - channel_id, - message_id: interaction.id, - guild_id: GuildId.make(interaction.guildId ?? ""), - bot_user_id, - bot_role_id: config.discordRoleId, - author_id: interaction.user.id, - author_is_bot: false, - mentions_everyone: false, + threadId, + channelId, + messageId: interaction.id, + guildId: GuildId.make(interaction.guildId ?? ""), + botUserId, + botRoleId: config.discordRoleId, + authorId: interaction.user.id, + authorIsBot: false, + mentionsEveryone: false, mentions: Mention.make({ - user_ids: bot_user_id.length > 0 ? [bot_user_id] : [], - role_ids: [], + userIds: botUserId.length > 0 ? [botUserId] : [], + roleIds: [], }), content: text, }) - const ingest = ledger.dedup(event.message_id).pipe( - Effect.flatMap((fresh) => { - if (!fresh) return Effect.void - return Effect.sync(() => { - chats.set(event.thread_id, thread) - input.unsafeOffer(event) - }) - }), - Effect.catchAll(() => Effect.void), - ) - yield* ingest + yield* Effect.sync(() => { + chats.set(event.threadId, thread) + input.unsafeOffer(event) + }) yield* acknowledge(interaction, COMMAND_ACK) }) void Runtime.runPromise(runtime)(handle) @@ -621,18 +463,18 @@ export class DiscordConversationServices { events: Stream.fromQueue(input, { shutdown: false }), }) - const channelOf = (thread_id: ThreadId, action: Action["kind"]) => { - const channel = chats.get(thread_id) + const channelOf = (threadId: ThreadId, action: Action["kind"]) => { + const channel = chats.get(threadId) if (channel) return Effect.succeed(channel) - return Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe( + return Effect.tryPromise(() => client.channels.fetch(threadId)).pipe( Effect.flatMap((fetched) => { const thread = asThreadChannel(fetched) if (thread) { - chats.set(thread_id, thread) + chats.set(threadId, thread) return Effect.succeed(thread) } return DeliveryError.make({ - thread_id, + threadId, action, message: "missing-thread-channel", retriable: false, @@ -640,7 +482,7 @@ export class DiscordConversationServices { }), Effect.mapError((cause) => DeliveryError.make({ - thread_id, + threadId, action, message: messageOf(cause), retriable: deliveryRetriable(cause), @@ -648,13 +490,13 @@ export class DiscordConversationServices { ) } - const deliver = (thread_id: ThreadId, action: Action["kind"], send: Effect.Effect) => + const deliver = (threadId: ThreadId, action: Action["kind"], send: Effect.Effect) => Effect.gen(function* () { const attempts = yield* Ref.make(0) yield* send.pipe( Effect.mapError((cause) => DeliveryError.make({ - thread_id, + threadId, action, message: messageOf(cause), retriable: deliveryRetriable(cause), @@ -665,7 +507,7 @@ export class DiscordConversationServices { Effect.logWarning("Discord delivery attempt failed").pipe( Effect.annotateLogs({ event: "conversation.delivery.retry", - thread_id, + thread_id: threadId, action, attempt, retriable: error.retriable, @@ -681,7 +523,7 @@ export class DiscordConversationServices { Effect.logError("Discord delivery failed").pipe( Effect.annotateLogs({ event: "conversation.delivery.failed", - thread_id, + thread_id: threadId, action, attempts: attempt, retriable: error.retriable, @@ -693,34 +535,34 @@ export class DiscordConversationServices { ) }) - const sendTyping = (thread_id: ThreadId) => + const sendTyping = (threadId: ThreadId) => Effect.gen(function* () { - const channel = yield* channelOf(thread_id, "typing") - yield* deliver(thread_id, "typing", Effect.tryPromise(() => channel.sendTyping())) + const channel = yield* channelOf(threadId, "typing") + yield* deliver(threadId, "typing", Effect.tryPromise(() => channel.sendTyping())) }) - const sendText = (thread_id: ThreadId, action: "send" | "reply", text: string) => + const sendText = (threadId: ThreadId, action: "send" | "reply", text: string) => Effect.gen(function* () { - const channel = yield* channelOf(thread_id, action) + const channel = yield* channelOf(threadId, action) yield* Effect.forEach( splitForDiscord(cleanResponse(text)), - (chunk) => deliver(thread_id, action, Effect.tryPromise(() => channel.send(chunk))), + (chunk) => deliver(threadId, action, Effect.tryPromise(() => channel.send(chunk))), { discard: true }, ) }) const publish = (action: Action) => { - if (action.kind === "typing") return sendTyping(action.thread_id) - return sendText(action.thread_id, action.kind, action.text) + if (action.kind === "typing") return sendTyping(action.threadId) + return sendText(action.threadId, action.kind, action.text) } - const withTyping = (thread_id: ThreadId, self: Effect.Effect) => + const withTyping = (threadId: ThreadId, self: Effect.Effect) => Effect.scoped( Effect.gen(function* () { const pulse = publish( Typing.make({ kind: "typing", - thread_id, + threadId, }), ).pipe(Effect.catchAll(() => Effect.void)) yield* pulse @@ -736,14 +578,14 @@ export class DiscordConversationServices { const outbox = Outbox.of({ publish, withTyping }) const history = History.of({ - rehydrate: (thread_id, latest: string) => + rehydrate: (threadId, latest: string) => Effect.gen(function* () { - const channel = chats.get(thread_id) + const channel = chats.get(threadId) if (!channel) return latest return yield* buildHistoryReplayPrompt(channel, latest).pipe( Effect.mapError((cause) => HistoryError.make({ - thread_id, + threadId, message: messageOf(cause), retriable: true, })), @@ -754,40 +596,40 @@ export class DiscordConversationServices { const threads = Threads.of({ ensure: (event, name: string) => { if (event.kind === "thread_message") { - return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id })) + return Effect.succeed(ThreadRef.make({ threadId: event.threadId, channelId: event.channelId })) } - const known = roots.get(event.message_id) + const known = roots.get(event.messageId) if (known) { - return Effect.succeed(ThreadRef.make({ thread_id: known, channel_id: event.channel_id })) + return Effect.succeed(ThreadRef.make({ threadId: known, channelId: event.channelId })) } return Effect.gen(function* () { - const local = texts.get(event.channel_id) + const local = texts.get(event.channelId) const channel = local ? local - : yield* Effect.tryPromise(() => client.channels.fetch(event.channel_id)).pipe( + : yield* Effect.tryPromise(() => client.channels.fetch(event.channelId)).pipe( Effect.map((fetched) => asTextChannel(fetched)), Effect.mapError((cause) => ThreadEnsureError.make({ - channel_id: event.channel_id, + channelId: event.channelId, message: messageOf(cause), retriable: deliveryRetriable(cause), })), ) if (!channel) { return yield* ThreadEnsureError.make({ - channel_id: event.channel_id, + channelId: event.channelId, message: "missing-parent-channel", retriable: false, }) } - texts.set(event.channel_id, channel) - const base = refs.get(event.message_id) + texts.set(event.channelId, channel) + const base = refs.get(event.messageId) const thread = yield* Effect.tryPromise(() => channel.threads.create({ name, - startMessage: base ?? event.message_id, + startMessage: base ?? event.messageId, autoArchiveDuration: 60, }), ).pipe( @@ -801,15 +643,15 @@ export class DiscordConversationServices { )), Effect.mapError((cause) => ThreadEnsureError.make({ - channel_id: event.channel_id, + channelId: event.channelId, message: messageOf(cause), retriable: deliveryRetriable(cause), })), ) - const thread_id = ThreadId.make(thread.id) - chats.set(thread_id, thread) - stash(roots, root_ids, event.message_id, thread_id) - return ThreadRef.make({ thread_id, channel_id: event.channel_id }) + const threadId = ThreadId.make(thread.id) + chats.set(threadId, thread) + stash(roots, root_ids, event.messageId, threadId) + return ThreadRef.make({ threadId, channelId: event.channelId }) }) }, }) diff --git a/packages/discord/src/discord/catchup.test.ts b/packages/discord/src/discord/catchup.test.ts new file mode 100644 index 0000000000..60580a79d7 --- /dev/null +++ b/packages/discord/src/discord/catchup.test.ts @@ -0,0 +1,70 @@ +import { describe, expect } from "bun:test" +import { Effect, Option } from "effect" +import type { OffsetStore } from "../conversation/offsets" +import { effectTest } from "../test/effect" +import { catchupFromOffset } from "./catchup" + +const makeOffsets = () => { + const map = new Map() + const service: OffsetStore.Service = { + getOffset: (source_id) => Effect.succeed(Option.fromNullable(map.get(source_id))), + setOffset: (source_id, messageId) => + Effect.sync(() => { + map.set(source_id, messageId) + }), + } + return { service, map } +} + +describe("catchupFromOffset", () => { + effectTest("no offset seeds latest and does not ingest", () => { + const offsets = makeOffsets() + const ingested: Array = [] + return catchupFromOffset({ + source: "thread:t1", + pageSize: 2, + offsets: offsets.service, + fetchLatest: Effect.succeed(Option.some({ id: "m9" })), + fetchAfter: () => Effect.succeed([]), + idOf: (message) => message.id, + ingest: (message) => + Effect.sync(() => { + ingested.push(message.id) + }), + }).pipe( + Effect.tap((count) => Effect.sync(() => { + expect(count).toBe(0) + expect(ingested).toEqual([]) + expect(offsets.map.get("thread:t1")).toBe("m9") + })), + ) + }) + + effectTest("existing offset replays all pages in order", () => { + const offsets = makeOffsets() + offsets.map.set("thread:t1", "m1") + const ingested: Array = [] + const pages = new Map>([ + ["m1", [{ id: "m2" }, { id: "m3" }]], + ["m3", [{ id: "m4" }]], + ]) + + return catchupFromOffset({ + source: "thread:t1", + pageSize: 2, + offsets: offsets.service, + fetchLatest: Effect.succeed(Option.none()), + fetchAfter: (after) => Effect.succeed(pages.get(after) ?? []), + idOf: (message) => message.id, + ingest: (message) => + Effect.sync(() => { + ingested.push(message.id) + }), + }).pipe( + Effect.tap((count) => Effect.sync(() => { + expect(count).toBe(3) + expect(ingested).toEqual(["m2", "m3", "m4"]) + })), + ) + }) +}) diff --git a/packages/discord/src/discord/catchup.ts b/packages/discord/src/discord/catchup.ts new file mode 100644 index 0000000000..6e6e3ea702 --- /dev/null +++ b/packages/discord/src/discord/catchup.ts @@ -0,0 +1,42 @@ +import { Effect, Option } from "effect" +import { OffsetStore } from "../conversation/offsets" + +export const catchupFromOffset = (input: { + source: string + pageSize: number + offsets: OffsetStore.Service + fetchLatest: Effect.Effect, unknown> + fetchAfter: (after: string) => Effect.Effect, unknown> + idOf: (message: M) => string + ingest: (message: M) => Effect.Effect +}): Effect.Effect => { + const pull = (after: string): Effect.Effect => + input.fetchAfter(after).pipe( + Effect.flatMap((rows) => { + if (rows.length === 0) return Effect.succeed(0) + const last = rows.at(-1) + if (!last) return Effect.succeed(0) + return Effect.forEach(rows, input.ingest, { discard: true }).pipe( + Effect.zipRight( + rows.length < input.pageSize + ? Effect.succeed(rows.length) + : pull(input.idOf(last)).pipe(Effect.map((tail) => rows.length + tail)), + ), + ) + }), + ) + + return input.offsets.getOffset(input.source).pipe( + Effect.flatMap((offset) => { + if (Option.isNone(offset)) { + return input.fetchLatest.pipe( + Effect.flatMap((latest) => { + if (Option.isNone(latest)) return Effect.succeed(0) + return input.offsets.setOffset(input.source, input.idOf(latest.value)).pipe(Effect.as(0)) + }), + ) + } + return pull(offset.value) + }), + ) +} diff --git a/packages/discord/src/discord/conversation-channels.ts b/packages/discord/src/discord/conversation-channels.ts new file mode 100644 index 0000000000..5a240802bc --- /dev/null +++ b/packages/discord/src/discord/conversation-channels.ts @@ -0,0 +1,40 @@ +import { ChannelType } from "discord.js" +import type { GuildMember, Message, TextChannel, ThreadChannel } from "discord.js" +import { AppConfig } from "../config" + +export type ChatChannel = TextChannel | ThreadChannel + +export const isChannelAllowed = (channelId: string, categoryId: string | null, config: AppConfig.Service): boolean => { + if (config.allowedChannelIds.length > 0 && config.allowedChannelIds.includes(channelId)) return true + if (config.discordCategoryId && categoryId === config.discordCategoryId) return true + return false +} + +export const hasRequiredRole = (member: GuildMember | null, config: AppConfig.Service): boolean => { + if (!config.discordRequiredRoleId) return true + if (!member) return false + return member.roles.cache.has(config.discordRequiredRoleId) +} + +export const asThreadChannel = (value: unknown): ThreadChannel | null => { + if (typeof value !== "object" || value === null) return null + const type = (value as { type?: unknown }).type + if (type === ChannelType.PublicThread || type === ChannelType.PrivateThread) return value as ThreadChannel + return null +} + +export const asTextChannel = (value: unknown): TextChannel | null => { + if (typeof value !== "object" || value === null) return null + const type = (value as { type?: unknown }).type + if (type === ChannelType.GuildText) return value as TextChannel + return null +} + +export const isMentioned = (message: Message, botUserId: string, botRoleId: string): boolean => { + if (botUserId.length > 0 && message.mentions.users.has(botUserId)) return true + if (botRoleId.length > 0 && message.mentions.roles.has(botRoleId)) return true + if (botUserId.length > 0 && message.content.includes(`<@${botUserId}>`)) return true + if (botUserId.length > 0 && message.content.includes(`<@!${botUserId}>`)) return true + if (botRoleId.length > 0 && message.content.includes(`<@&${botRoleId}>`)) return true + return false +} diff --git a/packages/discord/src/discord/conversation-commands.ts b/packages/discord/src/discord/conversation-commands.ts new file mode 100644 index 0000000000..225d3d7004 --- /dev/null +++ b/packages/discord/src/discord/conversation-commands.ts @@ -0,0 +1,23 @@ +export const EMPTY_MENTION_REPLY = "Tag me with a question!" +export const SETUP_FAILURE_REPLY = "Something went wrong setting up the thread." +export const COMMAND_NOT_THREAD_REPLY = "Use this command inside a Discord thread." +export const COMMAND_FORBIDDEN_REPLY = "You don't have the required role for this command." +export const COMMAND_CHANNEL_REPLY = "This thread is not allowed for the bot." +export const COMMAND_ACK = "Running command in this thread..." + +export const COMMANDS = [ + { + name: "status", + description: "Show sandbox status for this thread", + }, + { + name: "reset", + description: "Destroy the sandbox session for this thread", + }, +] as const + +export const commandText = (name: string): string => { + if (name === "status") return "!status" + if (name === "reset") return "!reset" + return "" +} diff --git a/packages/discord/src/discord/conversation-delivery.ts b/packages/discord/src/discord/conversation-delivery.ts new file mode 100644 index 0000000000..144910ad95 --- /dev/null +++ b/packages/discord/src/discord/conversation-delivery.ts @@ -0,0 +1,33 @@ +import { Schedule } from "effect" +import { DeliveryError, messageOf } from "../conversation/model/errors" + +const statusOf = (cause: unknown): number | null => { + if (typeof cause !== "object" || cause === null) return null + const status = (cause as { status?: unknown }).status + if (typeof status === "number") return status + const code = (cause as { code?: unknown }).code + if (typeof code === "number") return code + return null +} + +export const deliveryRetriable = (cause: unknown): boolean => { + const status = statusOf(cause) + if (status === 429) return true + if (status !== null && status >= 500) return true + return false +} + +export const catchupBenign = (cause: unknown): boolean => { + const text = messageOf(cause).toLowerCase() + if (text.includes("missing access")) return true + if (text.includes("missing permissions")) return true + if (text.includes("unknown channel")) return true + if (text.includes("50001")) return true + if (text.includes("50013")) return true + return false +} + +export const deliveryRetry = Schedule.exponential("200 millis").pipe( + Schedule.intersect(Schedule.recurs(3)), + Schedule.whileInput((error: DeliveryError) => error.retriable), +) diff --git a/packages/discord/src/discord/conversation-history.ts b/packages/discord/src/discord/conversation-history.ts new file mode 100644 index 0000000000..8e1ba16faf --- /dev/null +++ b/packages/discord/src/discord/conversation-history.ts @@ -0,0 +1,47 @@ +import { Effect } from "effect" +import { type ChatChannel } from "./conversation-channels" + +const HISTORY_FETCH_LIMIT = 40 +const HISTORY_LINE_CHAR_LIMIT = 500 +const HISTORY_TOTAL_CHAR_LIMIT = 6000 + +export const buildHistoryReplayPrompt = Effect.fn("DiscordAdapter.buildHistoryReplayPrompt")( + function* (channel: ChatChannel, latest: string) { + const fetched = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: HISTORY_FETCH_LIMIT })) + const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp) + const lines = ordered + .filter((prior) => !prior.system) + .flatMap((prior) => { + const text = prior.content.replace(/\s+/g, " ").trim() + const files = prior.attachments.size > 0 + ? `[attachments: ${[...prior.attachments.values()].map((att) => att.name ?? "file").join(", ")}]` + : "" + const line = text || files + if (!line) return [] + const value = line.length > HISTORY_LINE_CHAR_LIMIT ? `${line.slice(0, HISTORY_LINE_CHAR_LIMIT)}...` : line + return [`${prior.author.bot ? "assistant" : "user"}: ${value}`] + }) + + const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines + if (prior.length === 0) return latest + + const selected = prior.reduceRight( + (state, candidate) => { + if (state.stop) return state + if (state.total + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && state.list.length > 0) { + return { ...state, stop: true } + } + return { list: [candidate, ...state.list], total: state.total + candidate.length, stop: false } + }, + { list: [] as ReadonlyArray, total: 0, stop: false }, + ).list + + return [ + "Conversation history from this same Discord thread (oldest to newest):", + selected.join("\n"), + "", + "Continue the same conversation and respond to the latest user message:", + latest, + ].join("\n") + }, +) diff --git a/packages/discord/src/errors.ts b/packages/discord/src/errors.ts index 847c703f8a..01401b735f 100644 --- a/packages/discord/src/errors.ts +++ b/packages/discord/src/errors.ts @@ -45,15 +45,32 @@ export class HealthCheckError extends Schema.TaggedError()( }, ) {} +export const OpenCodeFailureKind = Schema.Literal("session-missing", "sandbox-down", "non-recoverable") +export type OpenCodeFailureKind = typeof OpenCodeFailureKind.Type + +export const classifyOpenCodeFailure = (statusCode: number, body: string): OpenCodeFailureKind => { + if (statusCode === 404) return "session-missing" + if (statusCode === 0 || statusCode >= 500) return "sandbox-down" + const text = body.toLowerCase() + if (text.includes("sandbox not found") || text.includes("is the sandbox started")) return "sandbox-down" + return "non-recoverable" +} + export class OpenCodeClientError extends Schema.TaggedError()( "OpenCodeClientError", { operation: Schema.String, statusCode: Schema.Number, body: Schema.String, + kind: OpenCodeFailureKind, }, ) {} +export const isOpenCodeSandboxUnavailable = (error: OpenCodeClientError) => { + if (error.kind === "session-missing") return true + return error.kind === "sandbox-down" +} + export class SessionMissingError extends Schema.TaggedError()( "SessionMissingError", { diff --git a/packages/discord/src/http/health.ts b/packages/discord/src/http/health.ts index 62c244689c..133bf4cd64 100644 --- a/packages/discord/src/http/health.ts +++ b/packages/discord/src/http/health.ts @@ -3,7 +3,7 @@ import { BunHttpServer } from "@effect/platform-bun" import { Context, Effect, Layer } from "effect" import { AppConfig } from "../config" import { DiscordClient } from "../discord/client" -import { ThreadAgentPool } from "../sandbox/pool" +import { SessionStore } from "../session/store" export declare namespace HealthServer { export interface Service { @@ -17,7 +17,7 @@ export class HealthServer extends Context.Tag("@discord/HealthServer") @@ -26,8 +26,9 @@ export class HealthServer extends Context.Tag("@discord/HealthServer") Effect.succeed(0)), + const activeSessions = yield* sessions.listActive().pipe( + Effect.catchAll(() => Effect.succeed([])), + Effect.map((rows) => rows.length), ) return HttpServerResponse.unsafeJson({ ok: true, diff --git a/packages/discord/src/index.ts b/packages/discord/src/index.ts index d0ca89825a..3c1bc12592 100644 --- a/packages/discord/src/index.ts +++ b/packages/discord/src/index.ts @@ -1,67 +1,15 @@ -import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic" -import { FetchHttpClient } from "@effect/platform" -import { BunContext, BunRuntime } from "@effect/platform-bun" +import { BunRuntime } from "@effect/platform-bun" import { Effect, Layer } from "effect" -import { DiscordConversationServicesLive } from "./conversation/implementations/discord" -import { Conversation } from "./conversation/services/conversation" -import { ConversationLedger } from "./conversation/services/ledger" -import { AppConfig } from "./config" -import { SqliteDb } from "./db/client" +import { AppConversationLayer } from "./app/layers" +import { Conversation } from "./conversation/conversation" import { DiscordClient } from "./discord/client" -import { TurnRouter } from "./discord/turn-routing" import { HealthServer } from "./http/health" -import { LoggerLive } from "./observability/logger" -import { DaytonaService } from "./sandbox/daytona" -import { OpenCodeClient } from "./sandbox/opencode-client" -import { ThreadAgentPool } from "./sandbox/pool" -import { SandboxProvisioner } from "./sandbox/provisioner" -import { SessionStore } from "./sessions/store" -const AnthropicLayer = Layer.unwrapEffect( - Effect.gen(function* () { - const config = yield* AppConfig - return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe( - Layer.provide(AnthropicClient.layer({ - apiKey: config.openCodeZenApiKey, - apiUrl: "https://opencode.ai/zen", - })), - Layer.provide(FetchHttpClient.layer), - ) - }), -) - -type AppServices = - | AppConfig - | DiscordClient - | HealthServer - | OpenCodeClient - | SessionStore - | DaytonaService - | TurnRouter - | SandboxProvisioner - | ThreadAgentPool - | ConversationLedger - | Conversation - -const BaseLayer = Layer.mergeAll(AppConfig.layer, FetchHttpClient.layer, BunContext.layer, LoggerLive) -const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer) -const WithAnthropic = Layer.provideMerge(AnthropicLayer, WithSqlite) -const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithAnthropic) -const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona) -const WithRouting = Layer.provideMerge(TurnRouter.layer, WithOpenCode) -const WithSessions = Layer.provideMerge(SessionStore.layer, WithRouting) -const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions) -const WithSandbox = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner) -const WithLedger = Layer.provideMerge(ConversationLedger.layer, WithSandbox) -const WithDiscord = Layer.provideMerge(DiscordClient.layer, WithLedger) -const WithDiscordConversation = Layer.provideMerge(DiscordConversationServicesLive, WithDiscord) -const WithConversation = Layer.provideMerge(Conversation.layer, WithDiscordConversation) -const AppLayer = Layer.provideMerge(HealthServer.layer, WithConversation) as Layer.Layer +const AppLayer = Layer.provideMerge(HealthServer.layer, AppConversationLayer) const main = Effect.gen(function* () { const client = yield* DiscordClient const conversation = yield* Conversation - yield* ThreadAgentPool yield* HealthServer yield* Effect.forkScoped(conversation.run) diff --git a/packages/discord/src/lib/actors/stateful.ts b/packages/discord/src/lib/actors/stateful.ts new file mode 100644 index 0000000000..768445dfe9 --- /dev/null +++ b/packages/discord/src/lib/actors/stateful.ts @@ -0,0 +1,23 @@ +export declare namespace StatefulActor { + export interface Stateful { + readonly get: () => S + readonly set: (state: S) => void + readonly update: (f: (state: S) => S) => S + } +} + +export const StatefulActor = { + make: (initial: S): StatefulActor.Stateful => { + let state = initial + return { + get: () => state, + set: (next) => { + state = next + }, + update: (f) => { + state = f(state) + return state + }, + } + }, +} as const diff --git a/packages/discord/src/sandbox/daytona.ts b/packages/discord/src/sandbox/daytona/service.ts similarity index 84% rename from packages/discord/src/sandbox/daytona.ts rename to packages/discord/src/sandbox/daytona/service.ts index a0c64fc541..d9e5864448 100644 --- a/packages/discord/src/sandbox/daytona.ts +++ b/packages/discord/src/sandbox/daytona/service.ts @@ -1,8 +1,8 @@ import { Daytona as DaytonaSDK, Image } from "@daytonaio/sdk" import { Context, Effect, Layer, Redacted, Schema } from "effect" -import { AppConfig } from "../config" -import { SandboxCreateError, SandboxExecError, SandboxNotFoundError, SandboxStartError } from "../errors" -import { GuildId, SandboxId, ThreadId, PreviewAccess } from "../types" +import { AppConfig } from "../../config" +import { SandboxCreateError, SandboxExecError, SandboxNotFoundError, SandboxStartError } from "../../errors" +import { GuildId, SandboxId, ThreadId, PreviewAccess } from "../../types" export class SandboxHandle extends Schema.Class("SandboxHandle")({ id: SandboxId, @@ -38,7 +38,7 @@ export declare namespace DaytonaService { } } -const discordBotImage = Image.base("node:22-bookworm-slim") +export const discordBotImage = Image.base("node:22-bookworm-slim") .runCommands( "apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*", "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" > /etc/apt/sources.list.d/github-cli.list && apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*", @@ -82,24 +82,36 @@ export class DaytonaService extends Context.Tag("@discord/DaytonaService") - sdk.create( - { - image: discordBotImage, - labels: { app: "opencord", threadId: opts.threadId, guildId: opts.guildId }, - autoStopInterval: 0, - autoArchiveInterval: 0, - }, - { timeout: opts.timeout }, - ), + snapshot.length > 0 + ? sdk.create( + { + ...base, + snapshot, + }, + { timeout: opts.timeout }, + ) + : sdk.create( + { + ...base, + image: discordBotImage, + }, + { timeout: opts.timeout }, + ), catch: (cause) => new SandboxCreateError({ cause }), }) - const sandboxId = SandboxId.make(sandbox.id) - return yield* toHandle( - sandboxId, - sandbox, - (cause) => new SandboxCreateError({ sandboxId, cause }), + const sandboxId = SandboxId.make(sandbox.id) + return yield* toHandle( + sandboxId, + sandbox, + (cause) => new SandboxCreateError({ sandboxId, cause }), ) }, ) diff --git a/packages/discord/src/sandbox/daytona/snapshot.ts b/packages/discord/src/sandbox/daytona/snapshot.ts new file mode 100644 index 0000000000..3cc8135663 --- /dev/null +++ b/packages/discord/src/sandbox/daytona/snapshot.ts @@ -0,0 +1,44 @@ +import { Daytona as DaytonaSDK } from "@daytonaio/sdk" +import { discordBotImage } from "./service" + +const now = () => new Date().toISOString().slice(0, 10).replaceAll("-", "") + +const apiKey = process.env["DAYTONA_API_KEY"]?.trim() ?? "" +if (apiKey.length === 0) { + console.error("DAYTONA_API_KEY is required") + process.exit(1) +} + +const name = Bun.argv[2]?.trim() || `opencode-discord-${now()}` +const regionId = process.env["DAYTONA_REGION_ID"]?.trim() ?? "" + +const run = async () => { + const sdk = new DaytonaSDK({ + apiKey, + _experimental: {}, + }) + const snapshot = await sdk.snapshot.create( + regionId.length > 0 + ? { + name, + image: discordBotImage, + regionId, + } + : { + name, + image: discordBotImage, + }, + { + onLogs: (chunk) => process.stdout.write(chunk), + }, + ) + const active = await sdk.snapshot.activate(snapshot) + console.log(`snapshot ready: ${active.name}`) + console.log(`set DAYTONA_SNAPSHOT=${active.name}`) +} + +void run().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error(`snapshot creation failed: ${message}`) + process.exit(1) +}) diff --git a/packages/discord/src/sandbox/opencode-client.ts b/packages/discord/src/sandbox/opencode/client.ts similarity index 91% rename from packages/discord/src/sandbox/opencode-client.ts rename to packages/discord/src/sandbox/opencode/client.ts index ba79711226..32670792ba 100644 --- a/packages/discord/src/sandbox/opencode-client.ts +++ b/packages/discord/src/sandbox/opencode/client.ts @@ -1,7 +1,7 @@ import { Context, Effect, Layer, ParseResult, Schema, Schedule } from "effect" import { HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "@effect/platform" -import { HealthCheckError, OpenCodeClientError } from "../errors" -import { PreviewAccess, SessionId } from "../types" +import { classifyOpenCodeFailure, HealthCheckError, OpenCodeClientError } from "../../errors" +import { PreviewAccess, SessionId } from "../../types" const HealthResponse = Schema.Struct({ healthy: Schema.Boolean, @@ -108,6 +108,14 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient") + OpenCodeClientError.make({ + operation, + statusCode, + body, + kind: classifyOpenCodeFailure(statusCode, body), + }) + const mapErrors = ( operation: string, effect: Effect.Effect, @@ -115,11 +123,11 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient") - new OpenCodeClientError({ operation, statusCode: err.response.status, body: err.message }), + openCodeError(operation, err.response.status, err.message), RequestError: (err) => - new OpenCodeClientError({ operation, statusCode: 0, body: err.message }), + openCodeError(operation, 0, err.message), ParseError: (err) => - new OpenCodeClientError({ operation, statusCode: 0, body: `Decode: ${err.message}` }), + openCodeError(operation, 0, `Decode: ${err.message}`), }), ) @@ -172,10 +180,10 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient") err.response.status === 404 ? Effect.succeed(false) - : new OpenCodeClientError({ operation: "sessionExists", statusCode: err.response.status, body: err.message }), + : openCodeError("sessionExists", err.response.status, err.message), ), Effect.catchTag("RequestError", (err) => - new OpenCodeClientError({ operation: "sessionExists", statusCode: 0, body: err.message }), + openCodeError("sessionExists", 0, err.message), ), ) diff --git a/packages/discord/src/sandbox/pool.test.ts b/packages/discord/src/sandbox/pool.test.ts deleted file mode 100644 index d2f8b82be9..0000000000 --- a/packages/discord/src/sandbox/pool.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect, Layer, Option } from "effect" -import { OpenCodeClientError } from "../errors" -import { SessionStore } from "../sessions/store" -import { effectTest, testConfigLayer } from "../test/effect" -import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types" -import { OpenCodeClient } from "./opencode-client" -import { ResumeFailed, SandboxProvisioner } from "./provisioner" -import { ThreadAgentPool } from "./pool" - -const threadId = ThreadId.make("t1") -const channelId = ChannelId.make("c1") -const guildId = GuildId.make("g1") - -const session = SessionInfo.make({ - threadId, - channelId, - guildId, - sandboxId: SandboxId.make("sb1"), - sessionId: SessionId.make("s1"), - previewUrl: "https://preview.example", - previewToken: null, - status: "active", - lastError: null, - resumeFailCount: 0, -}) - -const store = () => { - let row = Option.some(session) - return SessionStore.of({ - upsert: (next) => - Effect.sync(() => { - row = Option.some(next) - }), - getByThread: () => Effect.succeed(row), - hasTrackedThread: () => Effect.succeed(Option.isSome(row)), - getActive: () => - Option.isSome(row) && row.value.status === "active" - ? Effect.succeed(row) - : Effect.succeed(Option.none()), - markActivity: () => Effect.void, - markHealthOk: () => Effect.void, - updateStatus: (_threadId, status) => - Effect.sync(() => { - if (Option.isNone(row)) return - row = Option.some(row.value.withStatus(status)) - }), - incrementResumeFailure: () => Effect.void, - listActive: () => - Option.isSome(row) && row.value.status === "active" - ? Effect.succeed([row.value] as const) - : Effect.succeed([] as const), - listTrackedThreads: () => - Option.isSome(row) && row.value.status !== "destroyed" - ? Effect.succeed([row.value.threadId] as const) - : Effect.succeed([] as const), - listStaleActive: () => Effect.succeed([] as const), - listExpiredPaused: () => Effect.succeed([] as const), - }) -} - -const provisioner = SandboxProvisioner.of({ - provision: () => Effect.succeed(session), - resume: () => Effect.succeed(ResumeFailed.make({ allowRecreate: true })), - ensureActive: ({ current }) => Effect.succeed(Option.isSome(current) ? current.value : session), - ensureHealthy: () => Effect.succeed(true), - recoverSendFailure: (_threadId, next) => Effect.succeed(next.withStatus("paused")), - pause: (_threadId, next) => Effect.succeed(next.withStatus("paused")), - destroy: (_threadId, next) => Effect.succeed(next.withStatus("destroyed")), -}) - -const client = (statusCode: number, body: string) => - OpenCodeClient.of({ - waitForHealthy: () => Effect.succeed(true), - createSession: () => Effect.succeed(SessionId.make("s2")), - sessionExists: () => Effect.succeed(true), - listSessions: () => Effect.succeed([]), - sendPrompt: () => Effect.fail(new OpenCodeClientError({ operation: "sendPrompt", statusCode, body })), - abortSession: () => Effect.void, - }) - -const withPool = ( - statusCode: number, - body: string, - run: Effect.Effect, -) => { - const deps = Layer.mergeAll( - testConfigLayer, - Layer.succeed(SessionStore, store()), - Layer.succeed(SandboxProvisioner, provisioner), - Layer.succeed(OpenCodeClient, client(statusCode, body)), - ) - return run.pipe( - Effect.provide(ThreadAgentPool.layer.pipe(Layer.provide(deps))), - ) -} - -describe("ThreadAgentPool", () => { - effectTest("maps recoverable send failures to SandboxDeadError", () => - withPool( - 502, - "bad gateway", - Effect.gen(function* () { - const pool = yield* ThreadAgentPool - const agent = yield* pool.getOrCreate(threadId, channelId, guildId) - const error = yield* agent.send("hello").pipe(Effect.flip) - expect(error._tag).toBe("SandboxDeadError") - }), - ), - ) - - effectTest("keeps non-recoverable send failures as OpenCodeClientError", () => - withPool( - 400, - "bad request", - Effect.gen(function* () { - const pool = yield* ThreadAgentPool - const agent = yield* pool.getOrCreate(threadId, channelId, guildId) - const error = yield* agent.send("hello").pipe(Effect.flip) - expect(error._tag).toBe("OpenCodeClientError") - }), - ), - ) -}) diff --git a/packages/discord/src/sandbox/pool.ts b/packages/discord/src/sandbox/pool.ts deleted file mode 100644 index b3f5767741..0000000000 --- a/packages/discord/src/sandbox/pool.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { Context, Duration, Effect, Layer, Option, Ref, Schedule } from "effect" -import { AppConfig } from "../config" -import { - type ConfigEncodeError, - DatabaseError, - type HealthCheckError, - type OpenCodeClientError, - type SandboxCreateError, - type SandboxExecError, - SandboxDeadError, - type SandboxNotFoundError, - type SandboxStartError, -} from "../errors" -import { ActorMap } from "../lib/actors/keyed" -import { logIgnore } from "../lib/log" -import { SessionStore } from "../sessions/store" -import { ChannelId, GuildId, PreviewAccess, SessionInfo, ThreadId } from "../types" -import { OpenCodeClient } from "./opencode-client" -import { SandboxProvisioner } from "./provisioner" - -/** Per-thread handle returned by ThreadAgentPool.resolve. */ -export interface ThreadAgent { - readonly threadId: ThreadId - /** Snapshot from when this agent handle was created. Prefer `current()` for live state. */ - readonly session: SessionInfo - readonly current: () => Effect.Effect - readonly send: (text: string) => Effect.Effect - readonly pause: (reason?: string) => Effect.Effect - readonly destroy: () => Effect.Effect -} - -type Runtime = { - readonly current: () => Effect.Effect - readonly ensure: ( - channelId: ChannelId, - guildId: GuildId, - ) => Effect.Effect< - SessionInfo, - | SandboxCreateError - | SandboxExecError - | SandboxNotFoundError - | SandboxStartError - | HealthCheckError - | OpenCodeClientError - | ConfigEncodeError - | SandboxDeadError - | DatabaseError - > - readonly send: (text: string) => Effect.Effect - readonly pause: (reason: string) => Effect.Effect - readonly destroy: (reason: string) => Effect.Effect -} - -export declare namespace ThreadAgentPool { - export interface Service { - /** Get an existing healthy ThreadAgent or create one. */ - readonly getOrCreate: ( - threadId: ThreadId, - channelId: ChannelId, - guildId: GuildId, - ) => Effect.Effect< - ThreadAgent, - | SandboxCreateError - | SandboxExecError - | SandboxNotFoundError - | SandboxStartError - | HealthCheckError - | OpenCodeClientError - | ConfigEncodeError - | SandboxDeadError - | DatabaseError - > - readonly hasTrackedThread: (threadId: ThreadId) => Effect.Effect - readonly getTrackedSession: (threadId: ThreadId) => Effect.Effect, DatabaseError> - readonly getActiveSessionCount: () => Effect.Effect - readonly pauseSession: (threadId: ThreadId, reason?: string) => Effect.Effect - readonly destroySession: (threadId: ThreadId) => Effect.Effect - } -} - -export class ThreadAgentPool extends Context.Tag("@discord/ThreadAgentPool")< - ThreadAgentPool, - ThreadAgentPool.Service ->() { - static readonly layer = Layer.scoped( - ThreadAgentPool, - Effect.gen(function* () { - const config = yield* AppConfig - const provisioner = yield* SandboxProvisioner - const oc = yield* OpenCodeClient - const store = yield* SessionStore - - const runtime = ( - threadId: ThreadId, - state: Ref.Ref>, - ): Runtime => { - const dead = (error: OpenCodeClientError) => { - if (error.statusCode === 404) return true - if (error.statusCode === 0 || error.statusCode >= 500) return true - const body = error.body.toLowerCase() - if (body.includes("sandbox not found")) return true - if (body.includes("is the sandbox started")) return true - return false - } - - const lookup = Effect.fnUntraced(function* () { - const loaded = yield* Ref.get(state) - if (Option.isSome(loaded)) return loaded - return Option.none() - }) - - const current = Effect.fnUntraced(function* () { - const session = yield* lookup() - if (Option.isSome(session)) return session.value - return yield* new DatabaseError({ - cause: new Error(`missing session for thread ${threadId}`), - }) - }) - - const ensure = (channelId: ChannelId, guildId: GuildId) => - Effect.gen(function* () { - const next = yield* provisioner.ensureActive({ - threadId, - channelId, - guildId, - current: yield* Ref.get(state), - }) - yield* Ref.set(state, Option.some(next)) - return next - }) - - const send = (text: string) => - Effect.gen(function* () { - yield* store.markActivity(threadId) - const session = yield* current() - return yield* oc.sendPrompt(PreviewAccess.from(session), session.sessionId, text).pipe( - Effect.catchTag("OpenCodeClientError", (error) => - provisioner.recoverSendFailure(threadId, session, error).pipe( - Effect.flatMap((next) => - Ref.set(state, Option.some(next)), - ), - Effect.flatMap(() => { - const failure: OpenCodeClientError | SandboxDeadError = dead(error) - ? new SandboxDeadError({ - threadId, - reason: `OpenCode send failed (${error.statusCode})`, - }) - : error - return Effect.fail(failure) - }), - ), - ), - ) - }) - - const pause = (reason: string) => - Effect.gen(function* () { - const session = yield* lookup() - if (Option.isNone(session)) return - const next = yield* provisioner.pause(threadId, session.value, reason) - yield* Ref.set(state, Option.some(next)) - }) - - const destroy = (reason: string) => - Effect.gen(function* () { - const session = yield* lookup() - if (Option.isNone(session)) return - const next = yield* provisioner.destroy(threadId, session.value, reason) - yield* Ref.set(state, Option.some(next)) - }) - - return { current, ensure, send, pause, destroy } - } - - const actors: ActorMap.ActorMap = yield* ActorMap.make({ - idleTimeout: config.sandboxTimeout, - onIdle: (threadId) => - logIgnore( - runRuntime( - threadId, - (rt) => rt.pause("inactivity-timeout"), - { touch: false }, - ).pipe( - Effect.tap(() => actors.remove(threadId)), - ), - "idle-pause", - ), - load: (threadId) => store.getByThread(threadId).pipe(Effect.catchAll(() => Effect.succeed(Option.none()))), - save: (_threadId, session) => logIgnore(store.upsert(session), "save-session").pipe(Effect.asVoid), - }) - - const runRuntime = ( - threadId: ThreadId, - f: (rt: Runtime) => Effect.Effect, - options?: { touch?: boolean }, - ) => - actors.run( - threadId, - (state) => f(runtime(threadId, state)), - options, - ) - - const pauseNow = (threadId: ThreadId, reason: string) => - runRuntime(threadId, (rt) => rt.pause(reason), { touch: false }).pipe( - Effect.tap(() => actors.remove(threadId)), - ) - - const destroyNow = (threadId: ThreadId, reason: string) => - runRuntime(threadId, (rt) => rt.destroy(reason), { touch: false }).pipe( - Effect.tap(() => actors.remove(threadId)), - ) - - const makeAgent = (threadId: ThreadId, session: SessionInfo): ThreadAgent => ({ - threadId, - session, - current: () => - runRuntime(threadId, (rt) => rt.current(), { touch: false }), - send: (text: string) => - runRuntime(threadId, (rt) => rt.send(text)), - pause: (reason = "manual") => pauseNow(threadId, reason), - destroy: () => destroyNow(threadId, "agent-destroy"), - }) - - const getOrCreate = Effect.fn("ThreadAgentPool.getOrCreate")(function* ( - threadId: ThreadId, - channelId: ChannelId, - guildId: GuildId, - ) { - const session = yield* runRuntime( - threadId, - (rt) => rt.ensure(channelId, guildId), - ) - return makeAgent(threadId, session) - }) - - const hasTrackedThread = Effect.fn("ThreadAgentPool.hasTrackedThread")(function* (threadId: ThreadId) { - return yield* store.hasTrackedThread(threadId) - }) - - const getTrackedSession = Effect.fn("ThreadAgentPool.getTrackedSession")(function* (threadId: ThreadId) { - return yield* store.getByThread(threadId) - }) - - const getActiveSessionCount = Effect.fn("ThreadAgentPool.getActiveSessionCount")(function* () { - return (yield* store.listActive()).length - }) - - const pauseSession = Effect.fn("ThreadAgentPool.pauseSession")(function* ( - threadId: ThreadId, - reason = "manual", - ) { - yield* pauseNow(threadId, reason) - }) - - const destroySession = Effect.fn("ThreadAgentPool.destroySession")(function* (threadId: ThreadId) { - yield* destroyNow(threadId, "manual-destroy") - }) - - const cleanupPass = Effect.fnUntraced(function* () { - const stale = yield* store.listStaleActive( - Math.ceil(Duration.toMinutes(config.sandboxTimeout)) + config.staleActiveGraceMinutes, - ) - yield* Effect.forEach( - stale, - (row) => - logIgnore( - pauseNow(row.threadId, "cleanup-stale-active"), - "cleanup-pause", - ), - { concurrency: "unbounded", discard: true }, - ) - - const expired = yield* store.listExpiredPaused(config.pausedTtlMinutes) - yield* Effect.forEach( - expired, - (row) => - logIgnore( - destroyNow(row.threadId, "cleanup-expired-paused"), - "cleanup-destroy", - ), - { concurrency: "unbounded", discard: true }, - ) - }) - - yield* cleanupPass().pipe( - Effect.catchAll((error) => - Effect.logError("Cleanup loop failed").pipe( - Effect.annotateLogs({ event: "cleanup.loop.failed", error: String(error) }), - ), - ), - Effect.repeat(Schedule.spaced(config.cleanupInterval)), - Effect.forkScoped, - ) - - return ThreadAgentPool.of({ - getOrCreate, - hasTrackedThread, - getTrackedSession, - getActiveSessionCount, - pauseSession, - destroySession, - }) - }), - ) -} diff --git a/packages/discord/src/sandbox/provisioner.ts b/packages/discord/src/sandbox/provisioner.ts index f62976a820..efa8150cda 100644 --- a/packages/discord/src/sandbox/provisioner.ts +++ b/packages/discord/src/sandbox/provisioner.ts @@ -12,10 +12,10 @@ import { type SandboxNotFoundError, type SandboxStartError, } from "../errors" -import { SessionStore } from "../sessions/store" +import { SessionStore } from "../session/store" import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../types" -import { DaytonaService, type SandboxHandle } from "./daytona" -import { OpenCodeClient, OpenCodeSessionSummary } from "./opencode-client" +import { DaytonaService, type SandboxHandle } from "./daytona/service" +import { OpenCodeClient, OpenCodeSessionSummary } from "./opencode/client" import { logIgnore } from "../lib/log" @@ -51,7 +51,6 @@ export class ResumeFailed extends Schema.Class("ResumeFailed")({ }) {} export type ResumeResult = Resumed | ResumeFailed -type SendFailure = "session-missing" | "sandbox-down" | "non-recoverable" export declare namespace SandboxProvisioner { export interface Service { @@ -179,13 +178,6 @@ export class SandboxProvisioner extends Context.Tag("@discord/SandboxProvisioner const restartOpenCodeServe = 'pkill -f \'opencode serve --port 4096\' >/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1' - const classifySendError = (error: OpenCodeClientError): SendFailure => { - if (error.statusCode === 404) return "session-missing" - if (error.statusCode === 0 || error.statusCode >= 500) return "sandbox-down" - const body = error.body.toLowerCase() - if (body.includes("sandbox not found") || body.includes("is the sandbox started")) return "sandbox-down" - return "non-recoverable" - } const provision = Effect.fn("SandboxProvisioner.provision")(function* ( threadId: ThreadId, @@ -452,7 +444,7 @@ export class SandboxProvisioner extends Context.Tag("@discord/SandboxProvisioner session: SessionInfo, error: OpenCodeClientError, ) { - const kind = classifySendError(error) + const kind = error.kind if (kind === "non-recoverable") return session yield* store.incrementResumeFailure(threadId, String(error)) diff --git a/packages/discord/src/sessions/store.test.ts b/packages/discord/src/session/store.test.ts similarity index 99% rename from packages/discord/src/sessions/store.test.ts rename to packages/discord/src/session/store.test.ts index ce710cc313..b22249ec0a 100644 --- a/packages/discord/src/sessions/store.test.ts +++ b/packages/discord/src/session/store.test.ts @@ -17,6 +17,7 @@ const makeConfig = (databasePath: string) => discordCommandGuildId: "", databasePath, daytonaApiKey: Redacted.make("daytona"), + daytonaSnapshot: "", openCodeZenApiKey: Redacted.make("zen"), githubToken: "", logLevel: "info", diff --git a/packages/discord/src/sessions/store.ts b/packages/discord/src/session/store.ts similarity index 100% rename from packages/discord/src/sessions/store.ts rename to packages/discord/src/session/store.ts diff --git a/packages/discord/src/test/effect.ts b/packages/discord/src/test/effect.ts index 7d6502598e..325492c0eb 100644 --- a/packages/discord/src/test/effect.ts +++ b/packages/discord/src/test/effect.ts @@ -44,6 +44,7 @@ export const testConfigLayer = Layer.succeed( discordCommandGuildId: "", databasePath: ":memory:", daytonaApiKey: Redacted.make("test"), + daytonaSnapshot: "", openCodeZenApiKey: Redacted.make("test"), githubToken: "", logLevel: "info" as const,