From fb68042d312f64590d72a5ca8c9ede23d2bfc9d3 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Tue, 25 Aug 2026 22:58:19 -0500 Subject: [PATCH] release: promote v0.1.39 to main --- CHANGELOG.md | 21 ++ docs/mermaid-ascii-rendering.md | 2 +- docs/releasing.md | 17 ++ src/mendcode/install.ps1 | 56 +++- .../packages/extensions/zed/extension.toml | 12 +- src/mendcode/packages/opencode/package.json | 2 +- .../opencode/script/dev-rearm-smoke.ts | 2 +- .../cli/cmd/tui/component/dialog-command.tsx | 4 +- .../cli/cmd/tui/component/prompt/index.tsx | 102 +++++-- .../cli/cmd/tui/context/session-control.tsx | 67 ++++- .../src/cli/cmd/tui/routes/session/index.tsx | 56 +++- .../packages/opencode/src/effect/runner.ts | 250 +++++++++-------- .../opencode/src/installation/index.ts | 96 +++++-- .../packages/opencode/src/storage/db.ts | 10 +- .../opencode/src/storage/legacy-migration.ts | 254 ++++++++++++++++++ .../test/cli/tui/prompt-working-start.test.ts | 21 +- .../test/cli/tui/session-control.test.ts | 34 +++ .../opencode/test/effect/runner.test.ts | 42 ++- .../test/installation/installation.test.ts | 22 +- .../test/storage/legacy-migration.test.ts | 82 ++++++ 20 files changed, 956 insertions(+), 196 deletions(-) create mode 100644 src/mendcode/packages/opencode/src/storage/legacy-migration.ts create mode 100644 src/mendcode/packages/opencode/test/storage/legacy-migration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f28e7b7..cf090887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.1.39 - 2026-08-26 + +MendCode v0.1.39 makes interruption reliable across folders and recovers legacy installs without disrupting active sessions. + +### Changed + +- Route session controls through the owning workspace or directory instead of the currently selected project. +- Keep the global `Esc` interrupt available when the editor, transcript, or another TUI widget owns focus. +- Preserve explicit backend and provider retry state while their live heartbeat is still fresh. + +### Fixed + +- Fall back from a stale targeted cancellation to a bounded `session.abort` without leaving large or old sessions running indefinitely. +- Prevent cancellation outbox entries captured under an old route from replaying into another backend. +- Update Windows installations through the native PowerShell path instead of assuming WSL `/bin/bash` exists. +- Reconcile legacy SQLite migration journals transactionally when the existing schema is already present. + +### Tests + +- Run focused session-control, prompt, runner, installation, database-migration, workspace-routing, build, and startup smoke checks. + ## 0.1.38 - 2026-08-25 MendCode v0.1.38 hardens Smart Approval and keeps long-running sessions truthful during retries, reconnects, and large TUI turns. diff --git a/docs/mermaid-ascii-rendering.md b/docs/mermaid-ascii-rendering.md index 497c51bb..cc20a29d 100644 --- a/docs/mermaid-ascii-rendering.md +++ b/docs/mermaid-ascii-rendering.md @@ -1,6 +1,6 @@ # Mermaid ASCII Rendering -MendCode renders Mermaid fences as local, terminal-native ASCII diagrams in Markdown and rich chat presentation modes. The renderer keeps diagram semantics visible without requiring a browser, SVG canvas, or network request. This page reflects the current v0.1.38 behavior; it is documentation-only and does not require a version bump. +MendCode renders Mermaid fences as local, terminal-native ASCII diagrams in Markdown and rich chat presentation modes. The renderer keeps diagram semantics visible without requiring a browser, SVG canvas, or network request. This page reflects the current v0.1.39 behavior. ## Chat Canvas Behavior diff --git a/docs/releasing.md b/docs/releasing.md index 08ca9675..9e7ec18a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -20,6 +20,23 @@ Windows users who cannot run PowerShell can use `src/mendcode/install.cmd`. It uses the Windows-provided `curl.exe` and `tar.exe` tools and installs the baseline x64 asset (or the ARM64 asset) without requiring a PowerShell command. +On Windows, the supported installer and in-app updater use native PowerShell; +they never invoke the WSL `bash.exe` launcher. This matters on machines where +WSL is installed incompletely or has no Linux distribution. To recover an old +installation whose updater still reports `WSL ... execvpe(/bin/bash)`, run the +native installer once from PowerShell: + +```powershell +$script = Join-Path $env:TEMP "mendcode-install.ps1" +Invoke-WebRequest "https://raw.githubusercontent.com/MendCode/MendCode/main/src/mendcode/install.ps1" -OutFile $script +powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -SkipSetup +``` + +The installer updates only `~\.mendcode\bin\mendcode.exe`; it does not delete +the MendCode/OpenCode data directory or existing sessions. Newer binaries +schedule the replacement after the running process exits, avoiding Windows +executable file locks. + The release must include these assets: ```text diff --git a/src/mendcode/install.ps1 b/src/mendcode/install.ps1 index 64224138..a34ef67b 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -2,7 +2,8 @@ param( [string]$Version = $env:VERSION, [switch]$NoModifyPath, [switch]$Setup, - [switch]$SkipSetup + [switch]$SkipSetup, + [int]$WaitForExitPid = 0 ) $ErrorActionPreference = "Stop" @@ -76,6 +77,52 @@ function Write-SetupCommand { Write-Host ('Setup later: $env:OPENCODE_ROUTE=''{"type":"setup"}''; & "' + $binary + '"') -ForegroundColor DarkGray } +function Wait-MendCodeProcessExit { + param([int]$ProcessId) + + if ($ProcessId -le 0) { return } + while (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) { + Start-Sleep -Milliseconds 250 + } +} + +function Start-DeferredUpdate { + if ($env:MENDCODE_UPDATE_WORKER -eq "1") { return $false } + if (-not $env:MENDCODE_UPDATE_PARENT_PID) { return $false } + + [int]$parentPid = 0 + if (-not [int]::TryParse($env:MENDCODE_UPDATE_PARENT_PID, [ref]$parentPid) -or $parentPid -le 0) { + throw "MendCode could not schedule the Windows updater: invalid parent process id." + } + + $shell = Get-Command powershell.exe -ErrorAction SilentlyContinue + if (-not $shell) { $shell = Get-Command pwsh.exe -ErrorAction SilentlyContinue } + if (-not $shell) { + throw "MendCode could not schedule the Windows updater because PowerShell is unavailable." + } + + $workerArguments = [System.Collections.Generic.List[string]]::new() + $workerArguments.Add("-NoProfile") + $workerArguments.Add("-NonInteractive") + $workerArguments.Add("-ExecutionPolicy") + $workerArguments.Add("Bypass") + $workerArguments.Add("-File") + $workerArguments.Add('"' + $PSCommandPath + '"') + if ($Version) { + $workerArguments.Add("-Version") + $workerArguments.Add($Version) + } + if ($NoModifyPath) { $workerArguments.Add("-NoModifyPath") } + $workerArguments.Add("-SkipSetup") + $workerArguments.Add("-WaitForExitPid") + $workerArguments.Add($parentPid.ToString()) + + $env:MENDCODE_UPDATE_WORKER = "1" + Start-Process -FilePath $shell.Source -ArgumentList $workerArguments -WindowStyle Hidden | Out-Null + Write-Host "Update scheduled; it will finish after MendCode closes." -ForegroundColor DarkGreen + return $true +} + function Maybe-LaunchSetup { if ($SetupMode -eq "skip") { Write-SetupCommand @@ -180,6 +227,9 @@ if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Syst throw "install.ps1 is for Windows. On macOS or Linux, run: curl -fsSL https://raw.githubusercontent.com/MendCode/MendCode/main/src/mendcode/install | bash" } +if (Start-DeferredUpdate) { exit 0 } +Wait-MendCodeProcessExit -ProcessId $WaitForExitPid + New-Item -ItemType Directory -Force $InstallDir | Out-Null $target = Get-MendCodeTarget @@ -234,3 +284,7 @@ Write-Host " $(Join-Path $InstallDir "mendcode.exe") # run now in this termina Write-Host "" Write-Host "Open a new terminal to use: mendcode" Maybe-LaunchSetup + +if ($env:MENDCODE_UPDATE_SCRIPT_PATH -and (Test-Path $env:MENDCODE_UPDATE_SCRIPT_PATH)) { + Remove-Item -Force $env:MENDCODE_UPDATE_SCRIPT_PATH -ErrorAction SilentlyContinue +} diff --git a/src/mendcode/packages/extensions/zed/extension.toml b/src/mendcode/packages/extensions/zed/extension.toml index bfb92b53..31bbe998 100644 --- a/src/mendcode/packages/extensions/zed/extension.toml +++ b/src/mendcode/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "mendcode" name = "MendCode" description = "The open source coding agent." -version = "0.1.38" +version = "0.1.39" schema_version = 1 authors = ["MendCode"] repository = "https://github.com/MendCode/MendCode" @@ -11,26 +11,26 @@ name = "MendCode" icon = "./icons/opencode.svg" [agent_servers.mendcode.targets.darwin-aarch64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/mendcode-darwin-arm64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/mendcode-darwin-arm64.zip" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.darwin-x86_64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/mendcode-darwin-x64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/mendcode-darwin-x64.zip" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.linux-aarch64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/mendcode-linux-arm64.tar.gz" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/mendcode-linux-arm64.tar.gz" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.linux-x86_64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/mendcode-linux-x64.tar.gz" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/mendcode-linux-x64.tar.gz" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.windows-x86_64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/mendcode-windows-x64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/mendcode-windows-x64.zip" cmd = "./mendcode.exe" args = ["acp"] diff --git a/src/mendcode/packages/opencode/package.json b/src/mendcode/packages/opencode/package.json index a583cfca..c0acdbf4 100644 --- a/src/mendcode/packages/opencode/package.json +++ b/src/mendcode/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.1.38", + "version": "0.1.39", "name": "mendcode", "type": "module", "license": "MIT", diff --git a/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts b/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts index d993fcfb..b69264ce 100644 --- a/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts +++ b/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts @@ -2,7 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { once } from "node:events" import { probeRuntime } from "./dev-rearm" -const VERSION = "0.1.38" +const VERSION = "0.1.39" const PROJECT = "/tmp/mendcode-dev-rearm-smoke-project" function assert(condition: unknown, message: string): asserts condition { diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index ee8574e9..be135267 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -71,10 +71,10 @@ function init() { }) const result = { - trigger(name: string) { + trigger(name: string, options?: { bypassEnabled?: boolean }) { for (const option of entries()) { if (option.value === name) { - if (!isEnabled(option)) return + if (!options?.bypassEnabled && !isEnabled(option)) return option.onSelect?.(dialog) return } diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 42cf145b..3d8915f6 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -32,7 +32,12 @@ import { useSDK, type SDKConnectionStatus } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" -import { sessionControlAllowsPrompt, useSessionControl } from "@tui/context/session-control" +import { + sessionCancelResultNeedsHardAbort, + sessionControlAllowsPrompt, + resolveSessionControlRouting, + useSessionControl, +} from "@tui/context/session-control" import { useEvent } from "@tui/context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" @@ -122,6 +127,7 @@ import { const NATIVE_COMPACTION_SLASHES = new Set(["compact", "summarize"]) const ACTIVE_LOOP_STATES = new Set(["active", "sleeping", "working", "needs_input", "blocked"]) +const SESSION_INTERRUPT_FALLBACK_TARGET = "__mendcode_session_interrupt__" const trace = Log.create({ service: "tui.prompt" }) export type LoopWorkflowInfo = { @@ -157,6 +163,7 @@ export type PromptProps = { historyScope?: PromptHistoryScope historyItems?: () => readonly PromptInfo[] workspaceID?: string + directory?: string permissionMode?: string permissionModeLabel?: string permissionPending?: number @@ -553,8 +560,16 @@ function endPendingPromptDelivery(request: PromptAsyncInput) { } function cancelPendingPromptDeliveryForInterrupt(sessionID: string, targetMessageID?: string) { - if (!targetMessageID) return - cancelPendingPromptDelivery(sessionID, targetMessageID) + if (targetMessageID) { + cancelPendingPromptDelivery(sessionID, targetMessageID) + return + } + // A stale session may not have a target message in the current sync + // window. Cancel every local delivery before the hard session abort so a + // reconnect cannot replay a prompt the user just stopped. + for (const delivery of pendingPromptDeliveriesForSession(sessionID)) { + if (delivery.request.messageID) cancelPendingPromptDelivery(sessionID, delivery.request.messageID) + } } export function latestPendingAssistantID( @@ -718,9 +733,12 @@ export function hasInterruptibleSessionTurn(input: { export function sessionInterruptConfirmationAction(input: { activeTargetMessageID?: string armedTargetMessageID?: string + hasActiveTurn?: boolean }): "ignore" | "arm" | "interrupt" { - if (!input.activeTargetMessageID) return "ignore" - return input.armedTargetMessageID === input.activeTargetMessageID ? "interrupt" : "arm" + const activeTargetMessageID = + input.activeTargetMessageID ?? (input.hasActiveTurn ? SESSION_INTERRUPT_FALLBACK_TARGET : undefined) + if (!activeTargetMessageID) return "ignore" + return input.armedTargetMessageID === activeTargetMessageID ? "interrupt" : "arm" } export function sessionInterruptHint(input: { enabled: boolean; working: boolean; armed: boolean }) { @@ -961,7 +979,9 @@ export function Prompt(props: PromptProps) { const [interruptRequested, setInterruptRequested] = createSignal(false) const [interruptTargetMessageID, setInterruptTargetMessageID] = createSignal() const updateInterruptRequested = (interrupted: boolean, targetMessageID?: string) => { - setInterruptTargetMessageID(interrupted ? (targetMessageID ?? interruptTargetMessageID()) : undefined) + setInterruptTargetMessageID( + interrupted ? (targetMessageID ?? interruptTargetMessageID() ?? SESSION_INTERRUPT_FALLBACK_TARGET) : undefined, + ) setInterruptRequested(interrupted) props.onInterruptChange?.(interrupted) } @@ -1084,6 +1104,10 @@ export function Prompt(props: PromptProps) { return latestAssistant } const hasActiveWorkingAssistant = createMemo(() => Boolean(findActiveWorkingAssistant())) + // A stale/incomplete assistant receipt can outlive the runner in an older + // session. It is still an interruptible turn for Esc, even though it must + // not keep the normal Generating indicator alive forever. + const hasOrphanedAssistant = createMemo(() => Boolean(findOrphanedAssistant())) const hasPendingPromptDelivery = createMemo(() => { pendingPromptDeliveryRevision() return Boolean(props.sessionID && pendingPromptDeliveryIsActive(props.sessionID)) @@ -1650,8 +1674,15 @@ export function Prompt(props: PromptProps) { } createEffect(() => { if (store.interrupt === 0) return - const activeTargetMessageID = workingStatusActive() ? activeTurnTargetMessageID() : undefined - if (activeTargetMessageID === interruptArmedTargetMessageID) return + const activeTargetMessageID = + workingStatusActive() || hasOrphanedAssistant() || hasPendingPromptDelivery() + ? activeTurnTargetMessageID() + : undefined + if ( + activeTargetMessageID === interruptArmedTargetMessageID || + (!activeTargetMessageID && interruptArmedTargetMessageID === SESSION_INTERRUPT_FALLBACK_TARGET) + ) + return resetInterruptConfirmation() }) const currentMessageHistoryPrompt = createMemo(() => @@ -1766,7 +1797,7 @@ export function Prompt(props: PromptProps) { enabled: () => shouldEnableSessionInterrupt({ statusType: status().type, - hasActiveWorkingAssistant: hasActiveWorkingAssistant(), + hasActiveWorkingAssistant: hasActiveWorkingAssistant() || hasOrphanedAssistant(), hasPendingPromptDelivery: Boolean(props.sessionID && pendingPromptDeliveryIsActive(props.sessionID)), autocompleteVisible: Boolean(autocomplete?.visible), compactionActive: compactionActive(), @@ -1781,7 +1812,7 @@ export function Prompt(props: PromptProps) { const hasActiveTurn = hasInterruptibleSessionTurn({ statusType: status().type, hasDraft: store.prompt.input.trim().length > 0 || store.prompt.parts.length > 0, - hasActiveWorkingAssistant: hasActiveWorkingAssistant(), + hasActiveWorkingAssistant: hasActiveWorkingAssistant() || hasOrphanedAssistant(), hasPendingPromptDelivery: Boolean(props.sessionID && pendingPromptDeliveryIsActive(props.sessionID)), compactionActive: compactionActive(), }) @@ -1805,6 +1836,7 @@ export function Prompt(props: PromptProps) { const action = sessionInterruptConfirmationAction({ activeTargetMessageID: targetMessageID, armedTargetMessageID: interruptArmedTargetMessageID, + hasActiveTurn, }) if (action === "ignore") return if (action === "interrupt") { @@ -1815,7 +1847,7 @@ export function Prompt(props: PromptProps) { return } - interruptArmedTargetMessageID = targetMessageID + interruptArmedTargetMessageID = targetMessageID ?? SESSION_INTERRUPT_FALLBACK_TARGET setStore("interrupt", 1) if (interruptResetTimer) clearTimeout(interruptResetTimer) interruptResetTimer = setTimeout(() => { @@ -2301,11 +2333,45 @@ export function Prompt(props: PromptProps) { } function abortSession(sessionID: string, targetMessageID?: string) { - if (!targetMessageID) return - sessionControl.request({ sessionID, targetMessageID }) + if (targetMessageID) + sessionControl.request({ + sessionID, + targetMessageID, + workspaceID: props.workspaceID, + directory: props.directory, + }) updateInterruptRequested(true, targetMessageID) + cancelPendingPromptDeliveryForInterrupt(sessionID, targetMessageID) if (interruptRequest) return - interruptRequest = withTimeout(sessionControl.drain(), 2000, "Session interrupt timed out") + const hardAbort = async () => { + const routing = resolveSessionControlRouting({ + sessionWorkspaceID: props.workspaceID, + sessionDirectory: props.directory, + currentWorkspaceID: project.workspace.current(), + currentDirectory: sdk.directory, + }) + await withTimeout( + sdk.client.session.abort( + { + sessionID, + ...routing, + }, + { throwOnError: true }, + ), + 2000, + "Session abort timed out", + ) + } + const cancel = targetMessageID + ? withTimeout(sessionControl.drain(), 2000, "Session interrupt timed out") + .catch(() => false) + .then(async (delivered) => { + const control = sessionControl.status(sessionID) + const result = control.state === "stop_confirmed" ? control.result : undefined + if (sessionCancelResultNeedsHardAbort({ delivered, result })) await hardAbort() + }) + : hardAbort() + interruptRequest = cancel .catch(() => undefined) .finally(() => { interruptRequest = undefined @@ -3703,7 +3769,11 @@ export function Prompt(props: PromptProps) { const interruptHint = createMemo(() => { return sessionInterruptHint({ enabled: workingIndicatorConfig().showInterruptHint, - working: workingStatusActive(), + // Legacy sessions can retain an unfinished assistant after the backend + // lost its busy status. Keep the two-Esc hint visible for that emergency + // stop path even though we deliberately suppress the normal Generating + // indicator for stale activity. + working: workingStatusActive() || hasOrphanedAssistant() || hasPendingPromptDelivery(), armed: store.interrupt > 0, }) }) @@ -3875,7 +3945,7 @@ export function Prompt(props: PromptProps) { zIndex={1000} overflow="visible" > - + normalizeSessionCancelOutbox(next)) } - function request(input: { sessionID: string; targetMessageID: string }) { - const workspace = project.workspace.current() - const directory = workspace === undefined ? sdk.directory : undefined + function request(input: { sessionID: string; targetMessageID: string; workspaceID?: string; directory?: string }) { + const routing = resolveSessionControlRouting({ + sessionWorkspaceID: input.workspaceID, + sessionDirectory: input.directory, + currentWorkspaceID: project.workspace.current(), + currentDirectory: sdk.directory, + }) + const workspace = routing.workspace + const directory = routing.directory const id = `${workspace ?? directory ?? ""}\u0000${input.sessionID}\u0000${input.targetMessageID}` const current = entries() - if ( - sessionCancelRequestIsDuplicate({ - entries: current, - confirmedTargetMessageID: confirmed()[input.sessionID]?.targetMessageID, - sessionID: input.sessionID, - targetMessageID: input.targetMessageID, - }) + if (confirmed()[input.sessionID]?.targetMessageID === input.targetMessageID) return + const sameTarget = current.find( + (entry) => entry.sessionID === input.sessionID && entry.targetMessageID === input.targetMessageID, ) - return + if (sameTarget?.id === id) return + // Replace a legacy entry for the same turn when its route was captured + // before the session owner was known. Otherwise the stale entry would + // win the dedupe check and Esc would keep hitting the wrong folder. + const withoutSameTarget = sameTarget ? current.filter((entry) => entry.id !== sameTarget.id) : current persist([ - ...current, + ...withoutSameTarget, { id, ...(directory === undefined ? {} : { directory }), diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index a982e686..3a7165b2 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -49,6 +49,7 @@ import type { Part, PermissionRequest, Provider, + Session as SessionInfo, Message, ToolPart, UserMessage, @@ -57,6 +58,7 @@ import type { } from "@mendcode/sdk/v2" import type { OpencodeClient } from "@mendcode/sdk/v2" import { useLocal } from "@tui/context/local" +import { resolveSessionControlRouting } from "@tui/context/session-control" import * as Log from "@mendcode/core/util/log" import { Locale } from "@/util/locale" import { tuiText } from "@tui/util/text" @@ -580,6 +582,22 @@ export function shouldHandleGlobalSessionInterrupt(input: { return input.eventName === "escape" && !input.defaultPrevented && input.activeTurn && !input.pendingInput } +/** + * Keep the emergency Esc path available when a legacy/shared backend lost its + * busy status event but still left an unfinished assistant receipt in sync. + */ +export function sessionHasUnfinishedAssistant( + messages: ReadonlyArray<{ + role: string + error?: unknown + finish?: string + time: { created?: number; completed?: number } + }>, +) { + const latestAssistant = messages.findLast((message) => message.role === "assistant") + return Boolean(latestAssistant && latestAssistant.time.completed === undefined && !latestAssistant.error) +} + const context = createContext<{ width: number sessionID: string @@ -749,10 +767,18 @@ export function Session() { const sdk = useSDK() const [now, setNow] = createSignal(Date.now()) const [sessionInterruptRequested, setSessionInterruptRequested] = createSignal(false) + // Keep the owner route available before the session is inserted into the + // sync cache. This closes the small window where Esc could otherwise fall + // back to the previously selected folder while a shared/legacy session is + // still loading. + const [routeSessionInfo, setRouteSessionInfo] = createSignal() createEffect( on( () => route.sessionID, - () => setSessionInterruptRequested(false), + () => { + setSessionInterruptRequested(false) + setRouteSessionInfo(undefined) + }, { defer: true }, ), ) @@ -761,6 +787,7 @@ export function Session() { }) const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) + const sessionOwner = createMemo(() => session() ?? routeSessionInfo()) const [workflowTaskRuns, { refetch: refetchWorkflowTaskRuns }] = createResource( () => (session()?.parentID ? route.sessionID : undefined), async () => { @@ -861,6 +888,7 @@ export function Session() { const status = sync.data.session_status?.[route.sessionID]?.type return status === "busy" || status === "retry" }) + const hasUnfinishedAssistant = createMemo(() => sessionHasUnfinishedAssistant(activityMessages())) const activeTurnAssistantID = createMemo(() => { const unfinished = pending() if (unfinished) return unfinished @@ -1924,6 +1952,8 @@ export function Session() { return } + setRouteSessionInfo(result.data) + if (result.data.workspaceID !== previousWorkspace) { project.workspace.set(result.data.workspaceID) @@ -3584,14 +3614,17 @@ export function Session() { !shouldHandleGlobalSessionInterrupt({ eventName: evt.name, defaultPrevented: evt.defaultPrevented, - activeTurn: Boolean(pending() || sessionWorking() || hasLocalActiveTurn()), + activeTurn: Boolean(pending() || sessionWorking() || hasLocalActiveTurn() || hasUnfinishedAssistant()), pendingInput: disabled(), }) ) return evt.preventDefault() evt.stopPropagation() - command.trigger("session.interrupt") + // The prompt command normally requires prompt focus. The global Esc path + // is deliberately allowed to bypass that UI guard: a transcript, editor, + // widget, or stale legacy session must not make the emergency stop inert. + command.trigger("session.interrupt", { bypassEnabled: true }) }) function fillSessionPrompt(value: string) { @@ -3834,7 +3867,15 @@ export function Session() { }, onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] - if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) + if (status?.type !== "idle") { + const routing = resolveSessionControlRouting({ + sessionWorkspaceID: sessionOwner()?.workspaceID, + sessionDirectory: sessionOwner()?.directory, + currentWorkspaceID: project.workspace.current(), + currentDirectory: sdk.directory, + }) + await sdk.client.session.abort({ sessionID: route.sessionID, ...routing }).catch(() => {}) + } const revert = session()?.revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return @@ -4785,9 +4826,7 @@ export function Session() { selected={ permissionSelection()?.requestID === request().id ? permissionSelection()?.option : undefined } - onSelectionChange={(option) => - setPermissionSelection({ requestID: request().id, option }) - } + onSelectionChange={(option) => setPermissionSelection({ requestID: request().id, option })} /> )} @@ -4850,7 +4889,8 @@ export function Session() { disabled={promptDisabled()} historyScope={`session:${route.sessionID}`} historyItems={sessionPromptHistory} - workspaceID={project.workspace.current()} + workspaceID={sessionOwner()?.workspaceID} + directory={sessionOwner()?.directory} sessionID={route.sessionID} permissionMode={permissionMode()} permissionModeLabel={permissionModeLabel()} diff --git a/src/mendcode/packages/opencode/src/effect/runner.ts b/src/mendcode/packages/opencode/src/effect/runner.ts index 0fa49a77..5d9bcde2 100644 --- a/src/mendcode/packages/opencode/src/effect/runner.ts +++ b/src/mendcode/packages/opencode/src/effect/runner.ts @@ -2,14 +2,12 @@ import { Cause, Deferred, Effect, Exit, Fiber, Latch, Schema, Scope, Synchronize import * as Log from "@mendcode/core/util/log" const trace = Log.create({ service: "runner" }) +const TARGET_CANCEL_FINALIZER_TIMEOUT = "500 millis" export interface Runner { readonly state: State readonly busy: boolean - readonly ensureRunning: ( - work: Effect.Effect, - options?: EnsureRunningOptions, - ) => Effect.Effect + readonly ensureRunning: (work: Effect.Effect, options?: EnsureRunningOptions) => Effect.Effect readonly startShell: (work: Effect.Effect, ready?: Latch.Latch) => Effect.Effect readonly interruptCurrent: (options?: NonNullable) => Effect.Effect readonly interruptQueued: (options?: NonNullable) => Effect.Effect @@ -98,10 +96,13 @@ export const make = ( const idleIfCurrent = () => SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten) - const finishRun = (id: number, done: Deferred.Deferred, exit: Exit.Exit): Effect.Effect => - SynchronizedRef.modifyEffect( - ref, - (st) => Effect.gen(function* () { + const finishRun = ( + id: number, + done: Deferred.Deferred, + exit: Exit.Exit, + ): Effect.Effect => + SynchronizedRef.modifyEffect(ref, (st) => + Effect.gen(function* () { trace.trace("finish", { id, state: st._tag, success: Exit.isSuccess(exit) }) if (st._tag === "Running" && st.run.id === id) { return [ @@ -140,7 +141,7 @@ export const make = ( ): Effect.Effect> => Effect.gen(function* () { const id = next() - const fiber = yield* (Effect.yieldNow.pipe(Effect.andThen(work))).pipe( + const fiber = yield* Effect.yieldNow.pipe(Effect.andThen(work)).pipe( Effect.onExit((exit) => finishRun(id, done, exit)), Effect.forkIn(scope), ) @@ -148,9 +149,8 @@ export const make = ( }) as Effect.Effect> const finishShell = (id: number): Effect.Effect => - SynchronizedRef.modifyEffect( - ref, - (st) => Effect.gen(function* () { + SynchronizedRef.modifyEffect(ref, (st) => + Effect.gen(function* () { if (st._tag === "Shell" && st.shell.id === id) { return [idle, { _tag: "Idle" }] as const } @@ -158,7 +158,10 @@ export const make = ( const [next, ...remaining] = st.run if (!next) return [idle, { _tag: "Idle" }] as const const run = yield* startRun(next.work, next.done, next.key) - return [Effect.void, remaining.length ? { _tag: "RunningThenRun", run, next: remaining } : { _tag: "Running", run }] as const + return [ + Effect.void, + remaining.length ? { _tag: "RunningThenRun", run, next: remaining } : { _tag: "Running", run }, + ] as const } return [Effect.void, st] as const }), @@ -199,10 +202,13 @@ export const make = ( }) const ensureRunning = (work: Effect.Effect, options?: EnsureRunningOptions): Effect.Effect => - SynchronizedRef.modifyEffect( - ref, - (st) => Effect.gen(function* () { - trace.trace("ensure", { state: st._tag, queue: options?.queue ?? false, interrupt: Boolean(options?.interrupt) }) + SynchronizedRef.modifyEffect(ref, (st) => + Effect.gen(function* () { + trace.trace("ensure", { + state: st._tag, + queue: options?.queue ?? false, + interrupt: Boolean(options?.interrupt), + }) switch (st._tag) { case "Running": if (options?.queue) { @@ -293,91 +299,100 @@ export const make = ( }), ).pipe(Effect.flatten) as Effect.Effect - const cancelCurrent = (options?: { before?: Effect.Effect; cancelPending?: boolean }) => SynchronizedRef.modify(ref, (st) => { - switch (st._tag) { - case "Idle": - return [Effect.void, st] as const - case "Running": - return [ - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber) - // The interrupted work normally completes this in finishRun. Close - // it here as well so callers cannot hang if cancellation wins the - // brief race before the forked work begins its on-exit path. - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - yield* idleIfCurrent() - }), - { _tag: "Idle" } as const, - ] as const - case "RunningThenRun": - return [ - Effect.gen(function* () { - if (options?.cancelPending) yield* cancelPendingHandles(st.next) - yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - }), - options?.cancelPending ? { _tag: "Running", run: st.run } as const : st, - ] as const - case "Shell": - return [ - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* stopShell(st.shell) - yield* idleIfCurrent() - }), - { _tag: "Idle" } as const, - ] as const - case "ShellThenRun": - return [ - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* stopShell(st.shell) - yield* cancelPendingHandles(st.run) - yield* idleIfCurrent() - }), - { _tag: "Idle" } as const, - ] as const - } - }).pipe(Effect.flatten) - - const cancelCurrentIf = ( - key: string, - options?: { before?: Effect.Effect; cancelPending?: boolean; ignoreInterruptible?: boolean }, - ): Effect.Effect => - SynchronizedRef.modify( - ref, - (st): readonly [Effect.Effect, State] => { - if (st._tag !== "Running" && st._tag !== "RunningThenRun") { - return [Effect.succeed("not_running" as const), st] as const - } - if (st.run.key !== key) return [Effect.succeed("target_mismatch" as const), st] as const - if (!st.run.interruptible && options?.ignoreInterruptible !== true) - return [Effect.succeed("not_interruptible" as const), st] as const - if (st._tag === "Running") { + const cancelCurrent = (options?: { before?: Effect.Effect; cancelPending?: boolean }) => + SynchronizedRef.modify(ref, (st) => { + switch (st._tag) { + case "Idle": + return [Effect.void, st] as const + case "Running": return [ Effect.gen(function* () { yield* options?.before ?? Effect.void yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) - return "cancelled" as const + // The interrupted work normally completes this in finishRun. Close + // it here as well so callers cannot hang if cancellation wins the + // brief race before the forked work begins its on-exit path. + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* idleIfCurrent() }), - st, + { _tag: "Idle" } as const, ] as const - } + case "RunningThenRun": + return [ + Effect.gen(function* () { + if (options?.cancelPending) yield* cancelPendingHandles(st.next) + yield* options?.before ?? Effect.void + yield* Fiber.interrupt(st.run.fiber) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + }), + options?.cancelPending ? ({ _tag: "Running", run: st.run } as const) : st, + ] as const + case "Shell": + return [ + Effect.gen(function* () { + yield* options?.before ?? Effect.void + yield* stopShell(st.shell) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + case "ShellThenRun": + return [ + Effect.gen(function* () { + yield* options?.before ?? Effect.void + yield* stopShell(st.shell) + yield* cancelPendingHandles(st.run) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + } + }).pipe(Effect.flatten) + + const cancelCurrentIf = ( + key: string, + options?: { before?: Effect.Effect; cancelPending?: boolean; ignoreInterruptible?: boolean }, + ): Effect.Effect => + SynchronizedRef.modify(ref, (st): readonly [Effect.Effect, State] => { + if (st._tag !== "Running" && st._tag !== "RunningThenRun") { + return [Effect.succeed("not_running" as const), st] as const + } + if (st.run.key !== key) return [Effect.succeed("target_mismatch" as const), st] as const + if (!st.run.interruptible && options?.ignoreInterruptible !== true) + return [Effect.succeed("not_interruptible" as const), st] as const + if (st._tag === "Running") { return [ Effect.gen(function* () { - if (options?.cancelPending) yield* cancelPendingHandles(st.next) yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) + // Targeted cancellation is an HTTP control-plane request. Bound + // finalizer waiting so a stale or huge session cannot make Esc + // appear dead, while still giving ordinary tool cleanup time to + // persist the aborted assistant receipt. + yield* Fiber.interrupt(st.run.fiber).pipe(Effect.timeout(TARGET_CANCEL_FINALIZER_TIMEOUT), Effect.ignore) + // Cancellation is a control-plane operation. Do not wait for a + // provider/tool finalizer to resolve the run receipt: an old or + // huge session must be stoppable even when that cleanup is slow. + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* idleIfCurrent() return "cancelled" as const }), - options?.cancelPending ? { _tag: "Running", run: st.run } as const : st, + { _tag: "Idle" } as const, ] as const - }, - ).pipe(Effect.flatten) + } + return [ + Effect.gen(function* () { + if (options?.cancelPending) yield* cancelPendingHandles(st.next) + yield* options?.before ?? Effect.void + yield* Fiber.interrupt(st.run.fiber).pipe(Effect.timeout(TARGET_CANCEL_FINALIZER_TIMEOUT), Effect.ignore) + // Resolve the caller immediately; the interrupted fiber's exit + // hook may otherwise keep the HTTP cancel request hanging. + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* idleIfCurrent() + return "cancelled" as const + }), + options?.cancelPending ? ({ _tag: "Running", run: st.run } as const) : st, + ] as const + }).pipe(Effect.flatten) const cancel = cancelCurrent() @@ -391,7 +406,9 @@ export const make = ( yield* cancelPendingHandles(removed) return [ true, - remaining.length > 0 ? { _tag: "RunningThenRun", run: st.run, next: remaining } : { _tag: "Running", run: st.run }, + remaining.length > 0 + ? { _tag: "RunningThenRun", run: st.run, next: remaining } + : { _tag: "Running", run: st.run }, ] as const } if (st._tag === "ShellThenRun") { @@ -401,7 +418,9 @@ export const make = ( yield* cancelPendingHandles(removed) return [ true, - remaining.length > 0 ? { _tag: "ShellThenRun", shell: st.shell, run: remaining } : { _tag: "Shell", shell: st.shell }, + remaining.length > 0 + ? { _tag: "ShellThenRun", shell: st.shell, run: remaining } + : { _tag: "Shell", shell: st.shell }, ] as const } return [false, st] as const @@ -416,31 +435,32 @@ export const make = ( }) const interruptRun = (run: RunHandle, options?: NonNullable) => - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* Fiber.interrupt(run.fiber).pipe(Effect.ensuring(options?.after ?? Effect.void)) - yield* Deferred.await(run.done).pipe(Effect.exit, Effect.asVoid) - }) + Effect.gen(function* () { + yield* options?.before ?? Effect.void + yield* Fiber.interrupt(run.fiber).pipe(Effect.ensuring(options?.after ?? Effect.void)) + yield* Deferred.await(run.done).pipe(Effect.exit, Effect.asVoid) + }) const interruptShell = (shell: ShellHandle, options?: NonNullable) => - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* stopShell(shell).pipe(Effect.ensuring(options?.after ?? Effect.void)) - }) + Effect.gen(function* () { + yield* options?.before ?? Effect.void + yield* stopShell(shell).pipe(Effect.ensuring(options?.after ?? Effect.void)) + }) - const interruptCurrent = (options?: NonNullable) => SynchronizedRef.modify(ref, (st) => { - switch (st._tag) { - case "Idle": - return [Effect.void, st] as const - case "Running": - return [interruptRun(st.run, options), st] as const - case "RunningThenRun": - return [interruptRun(st.run, options), st] as const - case "Shell": - return [interruptShell(st.shell, options), st] as const - case "ShellThenRun": - return [interruptShell(st.shell, options), st] as const - } - }).pipe(Effect.flatten) + const interruptCurrent = (options?: NonNullable) => + SynchronizedRef.modify(ref, (st) => { + switch (st._tag) { + case "Idle": + return [Effect.void, st] as const + case "Running": + return [interruptRun(st.run, options), st] as const + case "RunningThenRun": + return [interruptRun(st.run, options), st] as const + case "Shell": + return [interruptShell(st.shell, options), st] as const + case "ShellThenRun": + return [interruptShell(st.shell, options), st] as const + } + }).pipe(Effect.flatten) const interruptQueued = (options?: NonNullable) => SynchronizedRef.modify(ref, (st) => { diff --git a/src/mendcode/packages/opencode/src/installation/index.ts b/src/mendcode/packages/opencode/src/installation/index.ts index 6f0e23a9..fecc67f1 100644 --- a/src/mendcode/packages/opencode/src/installation/index.ts +++ b/src/mendcode/packages/opencode/src/installation/index.ts @@ -4,8 +4,9 @@ import { CrossSpawnSpawner } from "@mendcode/core/cross-spawn-spawner" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" -import { existsSync, readFileSync } from "fs" +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs" import { fileURLToPath } from "url" +import { tmpdir } from "os" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "@mendcode/core/flag/flag" @@ -19,6 +20,9 @@ const log = Log.create({ service: "installation" }) const GITHUB_REPO = process.env.MENDCODE_GITHUB_REPO ?? "MendCode/MendCode" const GITHUB_RAW_INSTALL_URL = process.env.MENDCODE_INSTALL_URL ?? `https://raw.githubusercontent.com/${GITHUB_REPO}/main/src/mendcode/install` +const GITHUB_RAW_INSTALL_PS1_URL = + process.env.MENDCODE_INSTALL_PS1_URL ?? + `https://raw.githubusercontent.com/${GITHUB_REPO}/main/src/mendcode/install.ps1` const GITHUB_LATEST_RELEASE_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest` const PackageVersion = (() => { try { @@ -114,7 +118,7 @@ function installerShell() { const configured = process.env.MENDCODE_BASH_PATH?.trim() if (configured) return configured - const candidates = process.platform === "win32" ? ["bash.exe", "bash"] : ["/bin/bash", "/usr/bin/bash", "bash"] + const candidates = ["/bin/bash", "/usr/bin/bash", "bash"] for (const candidate of candidates) { if (candidate.startsWith("/") && existsSync(candidate)) return candidate const resolved = which(candidate) @@ -123,6 +127,36 @@ function installerShell() { return undefined } +function installerPowerShell() { + const configured = process.env.MENDCODE_POWERSHELL_PATH?.trim() + if (configured) return configured + + for (const candidate of ["pwsh.exe", "powershell.exe", "pwsh", "powershell"]) { + const resolved = which(candidate) + if (resolved) return resolved + } + return undefined +} + +export function usesNativeWindowsUpdater(platform: NodeJS.Platform = process.platform) { + return platform === "win32" +} + +export function windowsUpdaterArgs(scriptPath: string, target: string) { + return [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-Version", + target, + "-NoModifyPath", + "-SkipSetup", + ] +} + // Response schemas for external version APIs const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) @@ -180,6 +214,38 @@ export const layer: Layer.Layer Effect.fail(new UpgradeFailedError({ stderr: describeUpgradeFailure(defect) }))), - Effect.mapError( - (error) => - error instanceof UpgradeFailedError - ? error - : new UpgradeFailedError({ stderr: describeUpgradeFailure(error) }), + Effect.mapError((error) => + error instanceof UpgradeFailedError + ? error + : new UpgradeFailedError({ stderr: describeUpgradeFailure(error) }), ), ) @@ -263,7 +324,9 @@ export const layer: Layer.Layer { count: entries.length, mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", }) - if (Flag.OPENCODE_SKIP_MIGRATIONS) { + if (!Flag.OPENCODE_SKIP_MIGRATIONS) { + const reconciled = reconcileLegacyMigrationJournal(db, entries) + if (reconciled) { + log.info("reconciled legacy sqlite migration journal", { + count: entries.length, + }) + } + } else { for (const item of entries) { item.sql = "select 1;" } diff --git a/src/mendcode/packages/opencode/src/storage/legacy-migration.ts b/src/mendcode/packages/opencode/src/storage/legacy-migration.ts new file mode 100644 index 00000000..4663d478 --- /dev/null +++ b/src/mendcode/packages/opencode/src/storage/legacy-migration.ts @@ -0,0 +1,254 @@ +import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" +import { sql } from "drizzle-orm" + +export type LegacyMigrationEntry = { + sql: string + timestamp: number + name: string +} + +const JOURNAL_TABLE = "__drizzle_migrations" +const SAVEPOINT = "mendcode_legacy_migration_statement" +const BREAKPOINT = "--> statement-breakpoint" + +type Database = Pick +type TableInfoRow = { name: string } +type JournalRow = { name: string | null } + +function quoteIdentifier(value: string) { + return `"${value.replaceAll('"', '""')}"` +} + +/** + * Drizzle executes migration chunks separated by its breakpoint marker. A few + * older bundled migrations predate that marker and contain several statements, + * so split those chunks without cutting quoted semicolons. + */ +function splitStatements(sql: string) { + const statements: string[] = [] + let start = 0 + let quote: "'" | '"' | "`" | undefined + let depth = 0 + + for (let index = 0; index < sql.length; index += 1) { + const character = sql[index] + const next = sql[index + 1] + + if (quote) { + if (character === quote) { + if (next === quote && quote !== "`") { + index += 1 + continue + } + quote = undefined + } + continue + } + + if (character === "'" || character === '"' || character === "`") { + quote = character + continue + } + if (character === "(") { + depth += 1 + continue + } + if (character === ")") { + depth = Math.max(0, depth - 1) + continue + } + if (character === ";" && depth === 0) { + const statement = sql.slice(start, index).trim() + if (statement) statements.push(statement) + start = index + 1 + } + } + + const remainder = sql.slice(start).trim() + if (remainder) statements.push(remainder) + return statements +} + +function migrationStatements(sql: string) { + return sql + .split(BREAKPOINT) + .flatMap((chunk) => splitStatements(chunk)) + .map((statement) => statement.trim()) + .filter(Boolean) +} + +function tableInfo(db: Database, table: string) { + return db.all(`PRAGMA table_info(${quoteIdentifier(table)})`) +} + +function hasTable(db: Database, table: string) { + const rows = db.all<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${JSON.stringify(table)} LIMIT 1`, + ) + return rows.length > 0 +} + +function declaredTable(statement: string) { + const match = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s*\((.*)\)$/is.exec( + statement, + ) + if (!match) return + + const columns = [ + ...match[2].matchAll( + /(?:^|,)\s*(?!(?:PRIMARY|UNIQUE|CONSTRAINT|FOREIGN|CHECK)\b)[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s+/gi, + ), + ].map((entry) => entry[1]) + return { table: match[1], columns } +} + +function declaredAddedColumn(statement: string) { + const match = + /^ALTER\s+TABLE\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s+ADD\s+(?:COLUMN\s+)?[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?/i.exec( + statement, + ) + return match ? { table: match[1], column: match[2] } : undefined +} + +function declaredDroppedColumn(statement: string) { + const match = + /^ALTER\s+TABLE\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s+DROP\s+COLUMN\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?/i.exec( + statement, + ) + return match ? { table: match[1], column: match[2] } : undefined +} + +function declaredUpdatedTable(statement: string) { + const match = /^UPDATE\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s+SET\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?/i.exec( + statement, + ) + return match ? { table: match[1], column: match[2] } : undefined +} + +function errorText(error: unknown) { + const messages: string[] = [] + const seen = new Set() + let current: unknown = error + while (current && !seen.has(current)) { + seen.add(current) + if (current instanceof Error) messages.push(current.message) + else if (typeof current === "object" && current !== null && "message" in current) { + const message = (current as { message?: unknown }).message + if (typeof message === "string") messages.push(message) + } + current = + typeof current === "object" && current !== null && "cause" in current + ? (current as { cause?: unknown }).cause + : undefined + } + return messages.join("\n") || String(error) +} + +function isAlreadyApplied(db: Database, statement: string, error: unknown) { + const message = errorText(error).toLowerCase() + const normalized = statement.trim() + + if (/^create\s+table\b/i.test(normalized) && message.includes("already exists")) { + const declaration = declaredTable(normalized) + if (!declaration || !hasTable(db, declaration.table)) return false + const actual = new Set(tableInfo(db, declaration.table).map((row) => row.name)) + return declaration.columns.every((column) => actual.has(column)) + } + + if (/^create\s+(?:unique\s+)?index\b/i.test(normalized) && message.includes("already exists")) { + return true + } + + if (/^alter\s+table\b/i.test(normalized) && message.includes("duplicate column name")) { + const declaration = declaredAddedColumn(normalized) + return declaration ? tableInfo(db, declaration.table).some((row) => row.name === declaration.column) : false + } + + if (/^alter\s+table\b/i.test(normalized) && message.includes("no such column")) { + const declaration = declaredDroppedColumn(normalized) + return declaration ? !tableInfo(db, declaration.table).some((row) => row.name === declaration.column) : false + } + + if (/^update\s+/i.test(normalized) && message.includes("no such column")) { + // A legacy database may already have completed a data-move migration and + // dropped the source column. Only skip when the destination column exists. + const declaration = declaredUpdatedTable(normalized) + return declaration ? tableInfo(db, declaration.table).some((row) => row.name === declaration.column) : false + } + + if (/^drop\s+table\b/i.test(normalized) && message.includes("no such table")) return true + if (/^drop\s+index\b/i.test(normalized) && message.includes("no such index")) return true + return false +} + +function runStatement(db: Database, statement: string) { + db.run(`SAVEPOINT ${SAVEPOINT}`) + try { + db.run(statement) + db.run(`RELEASE SAVEPOINT ${SAVEPOINT}`) + } catch (error) { + try { + db.run(`ROLLBACK TO SAVEPOINT ${SAVEPOINT}`) + } finally { + db.run(`RELEASE SAVEPOINT ${SAVEPOINT}`) + } + if (!isAlreadyApplied(db, statement, error)) throw error + } +} + +function createJournal(db: Database) { + db.run( + `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(JOURNAL_TABLE)} (\n` + + ` id INTEGER PRIMARY KEY AUTOINCREMENT,\n` + + ` hash text NOT NULL,\n` + + ` created_at numeric,\n` + + ` name text NOT NULL,\n` + + ` applied_at text\n` + + `)`, + ) +} + +/** + * Repairs databases created before MendCode's Drizzle journal was persisted. + * It is deliberately opt-in: a database must already contain `project`, and + * unknown journal rows abort reconciliation rather than hiding schema drift. + */ +export function reconcileLegacyMigrationJournal(db: Database, entries: LegacyMigrationEntry[]) { + if (!hasTable(db, "project")) return false + + const journalExists = hasTable(db, JOURNAL_TABLE) + const journalRows = journalExists ? db.all(`SELECT name FROM ${quoteIdentifier(JOURNAL_TABLE)}`) : [] + if (journalRows.some((row) => !row.name)) return false + + const knownNames = new Set(entries.map((entry) => entry.name)) + if (journalRows.some((row) => row.name && !knownNames.has(row.name))) return false + + const applied = new Set(journalRows.flatMap((row) => (row.name ? [row.name] : []))) + const missing = entries.filter((entry) => !applied.has(entry.name)) + if (missing.length === 0) return false + + db.run("BEGIN") + try { + createJournal(db) + for (const entry of missing) { + for (const statement of migrationStatements(entry.sql)) runStatement(db, statement) + db.run( + sql`INSERT INTO ${sql.identifier(JOURNAL_TABLE)} (hash, created_at, name, applied_at) VALUES (${""}, ${entry.timestamp}, ${entry.name}, ${new Date().toISOString()})`, + ) + } + db.run("COMMIT") + return true + } catch (error) { + try { + db.run("ROLLBACK") + } catch { + // Preserve the original migration error. + } + throw error + } +} + +export const _test = { + migrationStatements, + splitStatements, +} diff --git a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts index b0ca7e2b..bd42e5a4 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts @@ -73,6 +73,7 @@ import { SESSION_BOTTOM_FOLLOW_REFLOW_DELAYS_MS, shouldKeepSessionBottomFollow, shouldHandleGlobalSessionInterrupt, + sessionHasUnfinishedAssistant, shouldReleaseSessionPagingBoundarySuppression, shouldRestoreSessionScrollAnchor, shouldUseSimpleSessionHistory, @@ -997,6 +998,22 @@ describe("resolveWorkingStartedAt", () => { expect(sessionInterruptHint({ enabled: true, working: true, armed: true })).toBe("[esc again to interrupt]") expect(sessionInterruptHint({ enabled: false, working: true, armed: true })).toBeUndefined() expect(sessionInterruptHint({ enabled: true, working: false, armed: true })).toBeUndefined() + expect(sessionInterruptConfirmationAction({ hasActiveTurn: true })).toBe("arm") + expect( + sessionInterruptConfirmationAction({ + hasActiveTurn: true, + armedTargetMessageID: "__mendcode_session_interrupt__", + }), + ).toBe("interrupt") + }) + + test("keeps the emergency Esc path for an unfinished legacy assistant receipt", () => { + expect(sessionHasUnfinishedAssistant([])).toBe(false) + expect(sessionHasUnfinishedAssistant([{ role: "assistant", time: {} }])).toBe(true) + expect( + sessionHasUnfinishedAssistant([{ role: "assistant", error: { name: "MessageAbortedError" }, time: {} }]), + ).toBe(false) + expect(sessionHasUnfinishedAssistant([{ role: "assistant", time: { completed: 1 } }])).toBe(false) }) test("accepts prompt interrupt only when the prompt textarea owns focus", () => { @@ -1487,9 +1504,7 @@ describe("resolveWorkingStartedAt", () => { const partsByMessage = { user_1: [{ type: "text", text: "inspect this workspace" }] } expect(sessionUserPromptForPermissionRequest({ messages, partsByMessage })).toBeUndefined() - expect( - sessionUserPromptForPermissionRequest({ messages, partsByMessage, messageID: "missing" }), - ).toBeUndefined() + expect(sessionUserPromptForPermissionRequest({ messages, partsByMessage, messageID: "missing" })).toBeUndefined() }) test("keys global loop cache entries by the requested page contract", () => { diff --git a/src/mendcode/packages/opencode/test/cli/tui/session-control.test.ts b/src/mendcode/packages/opencode/test/cli/tui/session-control.test.ts index 1b171d21..c2f9edfd 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/session-control.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/session-control.test.ts @@ -5,6 +5,8 @@ import { sessionCancelRetryDelay, sessionControlAllowsPrompt, sessionCancelRequestIsDuplicate, + sessionCancelResultNeedsHardAbort, + resolveSessionControlRouting, SESSION_CANCEL_AUTO_RETRY_MAX_ATTEMPTS, SESSION_CANCEL_AUTO_RETRY_WINDOW_MS, type SessionCancelOutboxEntry, @@ -155,4 +157,36 @@ describe("session control outbox", () => { }), ).toBe(false) }) + + test("falls back to a session abort when the targeted stop cannot prove the active turn", () => { + expect(sessionCancelResultNeedsHardAbort({ delivered: false })).toBe(true) + expect(sessionCancelResultNeedsHardAbort({ delivered: true, result: "target_mismatch" })).toBe(true) + expect(sessionCancelResultNeedsHardAbort({ delivered: true, result: "not_running" })).toBe(true) + expect(sessionCancelResultNeedsHardAbort({ delivered: true, result: "cancelled" })).toBe(false) + expect(sessionCancelResultNeedsHardAbort({ delivered: true, result: "already_terminal" })).toBe(false) + }) + + test("routes cancellation to the session owner instead of the currently selected folder", () => { + expect( + resolveSessionControlRouting({ + sessionDirectory: "/work/legacy-session", + currentWorkspaceID: "workspace-selected-now", + currentDirectory: "/work/current-folder", + }), + ).toEqual({ directory: "/work/legacy-session" }) + expect( + resolveSessionControlRouting({ + sessionWorkspaceID: "workspace-session-owner", + sessionDirectory: "/work/ignored-when-warped", + currentWorkspaceID: "workspace-selected-now", + currentDirectory: "/work/current-folder", + }), + ).toEqual({ workspace: "workspace-session-owner" }) + expect( + resolveSessionControlRouting({ + currentWorkspaceID: "workspace-selected-now", + currentDirectory: "/work/current-folder", + }), + ).toEqual({ workspace: "workspace-selected-now" }) + }) }) diff --git a/src/mendcode/packages/opencode/test/effect/runner.test.ts b/src/mendcode/packages/opencode/test/effect/runner.test.ts index d0a3ec7e..db10221c 100644 --- a/src/mendcode/packages/opencode/test/effect/runner.test.ts +++ b/src/mendcode/packages/opencode/test/effect/runner.test.ts @@ -125,10 +125,9 @@ describe("Runner", () => { return "second-result" }) - const [a, b] = yield* Effect.all( - [runner.ensureRunning(first), runner.ensureRunning(second, { queue: true })], - { concurrency: "unbounded" }, - ) + const [a, b] = yield* Effect.all([runner.ensureRunning(first), runner.ensureRunning(second, { queue: true })], { + concurrency: "unbounded", + }) expect(a).toBe("first-result") expect(b).toBe("second-result") @@ -166,10 +165,7 @@ describe("Runner", () => { ) .pipe(Effect.forkChild) const duplicateQueued = yield* runner - .ensureRunning( - Effect.fail("duplicate should not run"), - { queue: true, queueKey: "message" }, - ) + .ensureRunning(Effect.fail("duplicate should not run"), { queue: true, queueKey: "message" }) .pipe(Effect.forkChild) yield* Deferred.succeed(activeGate, undefined) @@ -640,6 +636,36 @@ describe("Runner", () => { }), ) + it.live( + "cancelCurrentIf returns while a run finalizer is still unwinding", + Effect.gen(function* () { + const s = yield* Scope.Scope + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const runner = Runner.make(s) + const active = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.ensuring(Deferred.await(release)), Effect.as("active")) + }), + { queueKey: "turn-finalizer" }, + ) + .pipe(Effect.forkChild) + + try { + yield* Deferred.await(started) + expect(yield* runner.cancelCurrentIf("turn-finalizer").pipe(Effect.timeout("250 millis"))).toBe("cancelled") + expect(runner.busy).toBe(false) + + yield* Deferred.succeed(release, undefined) + expect(Exit.isFailure(yield* Fiber.await(active).pipe(Effect.timeout("250 millis")))).toBe(true) + } finally { + yield* Deferred.succeed(release, undefined).pipe(Effect.ignore) + } + }), + ) + it.live( "work can be started after cancel", Effect.gen(function* () { diff --git a/src/mendcode/packages/opencode/test/installation/installation.test.ts b/src/mendcode/packages/opencode/test/installation/installation.test.ts index d21ed721..b849d33a 100644 --- a/src/mendcode/packages/opencode/test/installation/installation.test.ts +++ b/src/mendcode/packages/opencode/test/installation/installation.test.ts @@ -11,10 +11,7 @@ function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) return Layer.succeed(HttpClient.HttpClient, client) } -function mockSpawner( - handler: (cmd: string, args: readonly string[]) => string = () => "", - spawnError?: Error, -) { +function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "", spawnError?: Error) { const spawner = ChildProcessSpawner.make((command) => { if (spawnError) { return Effect.fail( @@ -63,6 +60,23 @@ function testLayer( } describe("installation", () => { + test("selects the native updater on Windows instead of the WSL bash launcher", () => { + expect(Installation.usesNativeWindowsUpdater("win32")).toBe(true) + expect(Installation.usesNativeWindowsUpdater("darwin")).toBe(false) + expect(Installation.windowsUpdaterArgs("C:\\Temp\\mendcode-upgrade.ps1", "0.1.39")).toEqual([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "C:\\Temp\\mendcode-upgrade.ps1", + "-Version", + "0.1.39", + "-NoModifyPath", + "-SkipSetup", + ]) + }) + test("uses package version for local display labels", () => { expect(Installation.displayVersion()).toMatch(/^\d+\.\d+\.\d+$/) expect(Installation.labelVersion()).toMatch(/^v\d+\.\d+\.\d+$/) diff --git a/src/mendcode/packages/opencode/test/storage/legacy-migration.test.ts b/src/mendcode/packages/opencode/test/storage/legacy-migration.test.ts new file mode 100644 index 00000000..71746be2 --- /dev/null +++ b/src/mendcode/packages/opencode/test/storage/legacy-migration.test.ts @@ -0,0 +1,82 @@ +import { Database } from "bun:sqlite" +import { describe, expect, test } from "bun:test" +import { drizzle } from "drizzle-orm/bun-sqlite" +import { reconcileLegacyMigrationJournal } from "../../src/storage/legacy-migration" + +const projectTable = ` + CREATE TABLE "project" ( + "id" text PRIMARY KEY, + "worktree" text NOT NULL, + "vcs" text, + "name" text, + "icon_url" text, + "icon_color" text, + "time_created" integer NOT NULL, + "time_updated" integer NOT NULL, + "time_initialized" integer, + "sandboxes" text NOT NULL + ) +` + +const initial = { + name: "20260127222353_familiar_lady_ursula", + timestamp: 1769552633000, + sql: `${projectTable}; + --> statement-breakpoint + CREATE TABLE "message" ("id" text PRIMARY KEY NOT NULL, "session_id" text NOT NULL); + --> statement-breakpoint + CREATE INDEX "message_session_idx" ON "message" ("session_id"); + `, +} + +describe("legacy sqlite migration reconciliation", () => { + test("rebuilds the journal and preserves already-created tables", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + db.run(projectTable) + + const changed = { + name: "20260128000000_add_workspace", + timestamp: 1769558400000, + sql: `ALTER TABLE "project" ADD COLUMN "workspace" text;`, + } + + expect(reconcileLegacyMigrationJournal(db, [initial, changed])).toBe(true) + expect( + sqlite + .query(`SELECT name FROM "__drizzle_migrations" ORDER BY id`) + .all() + .map((row) => (row as { name: string }).name), + ).toEqual([initial.name, changed.name]) + expect(sqlite.query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message'`).all()).toHaveLength( + 1, + ) + expect( + sqlite + .query(`PRAGMA table_info("project")`) + .all() + .some((row) => (row as { name: string }).name === "workspace"), + ).toBe(true) + + sqlite.close() + }) + + test("does not mask an incompatible existing table", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + db.run(`CREATE TABLE "project" ("id" text PRIMARY KEY, "worktree" text NOT NULL)`) + + expect(() => reconcileLegacyMigrationJournal(db, [initial])).toThrow(/CREATE TABLE/) + expect(sqlite.query(`SELECT name FROM sqlite_master WHERE name = '__drizzle_migrations'`).all()).toHaveLength(0) + + sqlite.close() + }) + + test("leaves a fresh database on the normal Drizzle migration path", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + expect(reconcileLegacyMigrationJournal(db, [initial])).toBe(false) + expect(sqlite.query(`SELECT name FROM sqlite_master WHERE name = '__drizzle_migrations'`).all()).toHaveLength(0) + sqlite.close() + }) +})