diff --git a/CHANGELOG.md b/CHANGELOG.md index 709e9588..1124d2a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.1.36 - 2026-08-23 + +MendCode v0.1.36 makes Loop and Workflow supervision durable, observable, and consistent with the workspace users actually inspect. + +### Changed + +- Show the interruption confirmation only after the first `Esc` press, on the left side of the activity row; a second `Esc` still targets the same active turn. +- Display completion-audit lifecycle, attempt, lease countdown or expiry age, last update, and the active auditor chat directly in the Workflow monitor. +- Open the current completion-auditor transcript before completed task transcripts while a Workflow audit is active. + +### Fixed + +- Reclaim expired completion-audit leases after restart even when every Workflow task is already complete, without falling back to a permanently queued run. +- Renew active completion-audit leases without increasing the attempt count, so long auditor turns cannot reject their own final evidence as stale. +- Avoid duplicate workspace-update events when a recovered Workflow reuses the same workspace lease. +- Execute Loop workers inside their authoritative leased worktree, preserve that path and branch through terminal state, and notify the parent chat with the exact workspace location. +- Keep Loop draft behavior aligned across legacy and Effect HTTP routes, including full contracts that omit optional cost or token caps. +- Prevent large transcripts from visibly unmounting when a submitted message moves into the optimistic render window. +- Preserve keyboard permission selection when OpenTUI reports synthetic or parked pointer movement. + +### Tests + +- Add focused regressions for audit restart recovery, duplicate lease events, auditor transcript routing, audit-state presentation, two-step interruption placement, Loop route parity, worktree ownership, large-session submission, and permission selection. +- Run 335 release-focused Loop, Workflow, TUI, session, permission, and route tests with isolated state, plus the native Darwin build and startup smoke checks. + ## 0.1.35 - 2026-08-22 MendCode v0.1.35 makes long-running sessions, Loop execution, Usage Insights, and local agent collaboration more immediate and reliable. diff --git a/src/mendcode/packages/extensions/zed/extension.toml b/src/mendcode/packages/extensions/zed/extension.toml index a9a35f8d..4f39e426 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.35" +version = "0.1.36" 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.35/mendcode-darwin-arm64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/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.35/mendcode-darwin-x64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/mendcode-darwin-x64.zip" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.linux-aarch64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.35/mendcode-linux-arm64.tar.gz" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/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.35/mendcode-linux-x64.tar.gz" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/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.35/mendcode-windows-x64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/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 b59f4d4c..7d58b062 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.35", + "version": "0.1.36", "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 b871ab03..6f01ef2d 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.35" +const VERSION = "0.1.36" 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/prompt/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index b27159b5..231a8586 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 @@ -718,8 +718,8 @@ export function sessionInterruptConfirmationAction(input: { } export function sessionInterruptHint(input: { enabled: boolean; working: boolean; armed: boolean }) { - if (!input.enabled || !input.working) return - return input.armed ? "[esc again to interrupt]" : "[esc to interrupt]" + if (!input.enabled || !input.working || !input.armed) return + return "[esc again to interrupt]" } export function clipboardPasteAction(content: { mime?: string; data?: string } | undefined): "image" | "text" | "none" { @@ -2639,11 +2639,6 @@ export function Prompt(props: PromptProps) { const queuedBehindActiveTurn = workingStatusActive() const submissionStartedAt = Date.now() submitPending = true - setSubmitPreflightActive(true) - if (!queuedBehindActiveTurn) { - setWorkingStartedAt(submissionStartedAt) - if (props.sessionID) workingStartedAtBySession.set(props.sessionID, submissionStartedAt) - } updateInterruptRequested(false) const modelConfig = await readModelsConfig(mend.root).catch(() => undefined) const configuredRole = Object.values(modelConfig?.roles || {}).find( @@ -2745,10 +2740,6 @@ export function Prompt(props: PromptProps) { local.model.variant.set(variant, { model: selectedModel }) updateInterruptRequested(false) - if (!queuedBehindActiveTurn) { - workingStartedAtBySession.set(sessionID, submissionStartedAt) - setWorkingStartedAt(submissionStartedAt) - } const inputText = submittedPrompt.inputText const nonTextParts = submittedPrompt.nonTextParts @@ -2797,9 +2788,55 @@ export function Prompt(props: PromptProps) { throw error } - // Do not expose a render between clearing the editor and mounting the - // optimistic turn. On large transcripts that intermediate layout pass can - // briefly leave the scrollbox with only virtual spacers on screen. + const skillPromptParts = + slashInvocation && skillInvocation + ? [ + { + id: PartID.ascending(), + type: "text" as const, + text: `/${slashInvocation.name}${slashInvocation.arguments ? ` ${slashInvocation.arguments}` : ""}`, + }, + { + id: PartID.ascending(), + type: "text" as const, + text: `Use the skill tool to load "${skillInvocation.name}", then follow its instructions for: ${slashInvocation.arguments}`, + synthetic: true, + metadata: { + kind: "skill_invocation", + skill: skillInvocation.name, + }, + }, + ...supplementalSlashPromptParts(promptParts), + ] + : undefined + const optimisticParts = + currentMode !== "shell" && + !(slashInvocation && NATIVE_COMPACTION_SLASHES.has(slashInvocation.name)) && + !(slashInvocation && slashServerCommand) + ? (skillPromptParts ?? promptParts) + : undefined + + // OpenTUI can render synchronously when either transcript or editor height + // changes. Commit the submitted-turn anchor and optimistic row together so + // virtualization never resolves an intermediate frame from stale scrollTop. + batch(() => { + setSubmitPreflightActive(true) + if (!queuedBehindActiveTurn) { + workingStartedAtBySession.set(sessionID, submissionStartedAt) + setWorkingStartedAt(submissionStartedAt) + } + props.onSubmit?.({ sessionID, messageID, inputRows: submittedInputRows, queuedBehindActiveTurn }) + if (!optimisticParts) return + insertOptimisticUserTurn({ + sessionID, + messageID, + agent: agent.name, + model: selectedModel, + variant, + created: Date.now(), + parts: optimisticParts, + }) + }) clearPromptForSubmit() history.append( { @@ -2838,35 +2875,6 @@ export function Prompt(props: PromptProps) { }) }) } else if (slashInvocation && skillInvocation) { - const visible = `/${slashInvocation.name}${slashInvocation.arguments ? ` ${slashInvocation.arguments}` : ""}` - const skillPromptParts = [ - { - id: PartID.ascending(), - type: "text" as const, - text: visible, - }, - { - id: PartID.ascending(), - type: "text" as const, - text: `Use the skill tool to load "${skillInvocation.name}", then follow its instructions for: ${slashInvocation.arguments}`, - synthetic: true, - metadata: { - kind: "skill_invocation", - skill: skillInvocation.name, - }, - }, - ...supplementalSlashPromptParts(promptParts), - ] - const optimisticCreated = Date.now() - insertOptimisticUserTurn({ - sessionID, - messageID, - agent: agent.name, - model: selectedModel, - variant, - created: optimisticCreated, - parts: skillPromptParts, - }) void deliverPrompt( { sessionID, @@ -2874,7 +2882,7 @@ export function Prompt(props: PromptProps) { agent: agent.name, model: selectedModel, variant, - parts: skillPromptParts, + parts: skillPromptParts!, }, { queuedBehindActiveTurn }, ) @@ -2896,16 +2904,6 @@ export function Prompt(props: PromptProps) { })), }) } else { - const optimisticCreated = Date.now() - insertOptimisticUserTurn({ - sessionID, - messageID, - agent: agent.name, - model: selectedModel, - variant, - created: optimisticCreated, - parts: promptParts, - }) void deliverPrompt( { sessionID, @@ -2919,7 +2917,6 @@ export function Prompt(props: PromptProps) { ) if (editorParts.length > 0) editor.markSelectionSent() } - props.onSubmit?.({ sessionID, messageID, inputRows: submittedInputRows, queuedBehindActiveTurn }) renderer.requestRender() if (props.sessionID) submitPending = false @@ -3888,6 +3885,13 @@ export function Prompt(props: PromptProps) { paddingRight={promptFooterPadRight()} > + + {(hint) => ( + + {hint()} + + )} + {(message) => ( @@ -3955,13 +3959,6 @@ export function Prompt(props: PromptProps) { - - {(hint) => ( - 0 ? theme.warning : theme.textMuted} wrapMode="none"> - {hint()} - - )} - {(meta) => ( 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 05402074..b582eca1 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 @@ -758,6 +758,7 @@ export function Session() { }), ) const [permissionModeSetting, setPermissionModeSetting] = createSignal("approval") + const [permissionSelection, setPermissionSelection] = createSignal<{ requestID: string; option: string }>() const children = createMemo(() => { const parentID = session()?.parentID ?? session()?.id return sync.data.session @@ -791,6 +792,7 @@ export function Session() { if (permissionModeSetting() === "full_access") return [] return pendingPermissions() }) + const activePermission = createMemo(() => permissions()[0]) const questions = createMemo(() => { return pendingInputSessionIDs().flatMap((sessionID) => sync.data.question[sessionID] ?? []) }) @@ -2398,10 +2400,17 @@ export function Session() { if (!scroll || scroll.isDestroyed) return false renderer.requestRender() const child = scroll.getChildren().find((item) => item.id === intent.messageID) - if (!child) return false + // A freshly reconciled OpenTUI child exists before Yoga has assigned its + // final geometry. Detaching sticky-follow while that child is still at + // y=0 makes the first frame jump to the oldest loaded transcript row. + if (!child || child.height <= 0 || child.y < scroll.y) return false applyingSubmitScroll = true const delta = child.y - scroll.y - intent.offset if (delta !== 0) scroll.scrollBy(delta) + // Keep sticky-follow active until the optimistic child is mounted and the + // identity offset has been applied. Detaching earlier lets OpenTUI reset a + // resized long transcript to the first loaded row for one visible frame. + setFollowSessionOutput(false) applyingSubmitScroll = false lastObservedScrollTop = scroll.scrollTop lastObservedScrollHeight = scroll.scrollHeight @@ -2413,7 +2422,10 @@ export function Session() { const scheduleSubmitScrollIntent = () => { const token = ++submitScrollToken - ;[0, 16, 50, 120, 240, 480].forEach((delay) => { + // Keep sticky-follow through the first layout frame. The first retry runs + // only after Yoga has positioned the optimistic row, then pins that row + // by identity without exposing the bounded store's oldest row. + ;[16, 50, 120, 240, 480].forEach((delay) => { setTimeout(() => reconcileSubmitScrollIntent(token), delay) }) } @@ -2427,7 +2439,6 @@ export function Session() { scrollAnchor = undefined suppressedPagingBoundary = undefined restoringSessionScroll = false - setFollowSessionOutput(false) setSubmitScrollIntent({ sessionID: input.sessionID, messageID: input.messageID, offset: input.offset ?? 0 }) scheduleSubmitScrollIntent() } @@ -4724,8 +4735,18 @@ export function Session() { - 0}> - + + {(request) => ( + + setPermissionSelection({ requestID: request().id, option }) + } + /> + )} 0}> diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 275b21ed..7f58a551 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -38,14 +38,14 @@ export function permissionPromptHoverSelection( // pointer's parked coordinates. It must never override a keyboard choice. if (event.type !== "move") return current - const moved = pointer.x !== event.x || pointer.y !== event.y pointer.x = event.x pointer.y = event.y - // A real pointer move is an explicit mouse intent. A replay at the same - // coordinates is only a layout/hover recheck and keeps keyboard ownership. - if (pointer.keyboardNavigation && !moved) return current - if (moved) pointer.keyboardNavigation = false + // OpenTUI reports coordinates relative to each option. A parked cursor can + // therefore look like it moved when a render rechecks another option. Once + // the keyboard owns selection, only an actual click transfers ownership back + // to the pointer; mouse down/up already do that below. + if (pointer.keyboardNavigation) return current return hovered } @@ -158,7 +158,11 @@ function TextBody(props: { title: string; description?: string; icon?: string }) ) } -export function PermissionPrompt(props: { request: PermissionRequest }) { +export function PermissionPrompt(props: { + request: PermissionRequest + selected?: string + onSelectionChange?: (option: string) => void +}) { const sdk = useSDK() const project = useProject() const sync = useSync() @@ -452,6 +456,8 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { header={header()} body={current.body} options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} + selected={props.selected} + onSelectionChange={props.onSelectionChange} escapeKey="reject" fullscreen onSelect={(option) => { @@ -567,6 +573,8 @@ function Prompt>(props: { header?: JSX.Element body: JSX.Element options: T + selected?: string + onSelectionChange?: (option: string) => void escapeKey?: keyof T fullscreen?: boolean onSelect: (option: keyof T) => void @@ -575,8 +583,9 @@ function Prompt>(props: { const keybind = useKeybind() const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] + const initial = keys.find((key) => String(key) === props.selected) ?? keys[0] const [store, setStore] = createStore({ - selected: keys[0], + selected: initial, expanded: false, }) const pointer: PermissionPromptPointerState = { @@ -585,6 +594,10 @@ function Prompt>(props: { const diffKey = Keybind.parse("ctrl+f")[0] const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() + const select = (option: keyof T) => { + setStore("selected", option) + props.onSelectionChange?.(String(option)) + } useKeyboard((evt) => { if (dialog.stack.length > 0) return @@ -594,7 +607,7 @@ function Prompt>(props: { pointer.keyboardNavigation = true const idx = keys.indexOf(store.selected) const next = keys[(idx - 1 + keys.length) % keys.length] - setStore("selected", next) + select(next) } if (evt.name === "right" || evt.name === "down" || evt.name == "l" || evt.name === "j") { @@ -602,7 +615,7 @@ function Prompt>(props: { pointer.keyboardNavigation = true const idx = keys.indexOf(store.selected) const next = keys[(idx + 1) % keys.length] - setStore("selected", next) + select(next) } if (evt.name === "return") { @@ -681,19 +694,20 @@ function Prompt>(props: { // selection only to real, changed-coordinate `move` events keeps // keyboard navigation stable while preserving deliberate mouse use. onMouseMove={(event) => { - setStore("selected", permissionPromptHoverSelection(event, store.selected, option, pointer)) + const next = permissionPromptHoverSelection(event, store.selected, option, pointer) + if (next !== store.selected) select(next) }} onMouseDown={(event) => { pointer.keyboardNavigation = false pointer.x = event.x pointer.y = event.y - setStore("selected", option) + select(option) }} onMouseUp={(event) => { pointer.keyboardNavigation = false pointer.x = event.x pointer.y = event.y - setStore("selected", option) + select(option) props.onSelect(option) }} > diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/workflows/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/workflows/index.tsx index 6479b1fb..80729576 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/workflows/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/workflows/index.tsx @@ -15,6 +15,8 @@ import { readPermissionsConfig, writePermissionsConfig, type PermissionMode } fr import { CommandDeck, CommandDeckContext } from "@tui/component/command-deck" import { workflowCurrentActivity, + workflowCompletionAuditActivity, + workflowMonitorAuditSessionID, workflowMonitorFooter, workflowMonitorLayout, workflowMonitorResumeTarget, @@ -22,6 +24,7 @@ import { workflowMonitorSessionID, workflowMonitorTaskRows, workflowRequestErrorText, + workflowTimeAgo, } from "@tui/util/workflow-view" import { workflowReceiptStateIsAnimated, @@ -40,6 +43,9 @@ type WorkflowSnapshot = Omit & { auditAttempts: number summary?: string failedCriteria?: string[] + auditLease?: { holder: string; expiresAt: number } + createdAt?: number + updatedAt?: number } } revision: Omit & { @@ -96,7 +102,13 @@ function receipt(snapshot: WorkflowSnapshot): WorkflowReceiptSnapshot { rootSessionID: snapshot.run.rootSessionID, createdAt: numeric(snapshot.run.createdAt), updatedAt: numeric(snapshot.run.updatedAt), - completion: snapshot.run.completion, + completion: snapshot.run.completion + ? { + ...snapshot.run.completion, + createdAt: optionalNumeric(snapshot.run.completion.createdAt), + updatedAt: optionalNumeric(snapshot.run.completion.updatedAt), + } + : undefined, }, phases: snapshot.phases.map((phase) => ({ id: phase.id, @@ -180,10 +192,9 @@ export function Workflows() { const loaded = detail.latest ?? detail() return loaded?.run.id === item?.run.id ? loaded : item }) - const currentActivity = createMemo(() => { + const workflowSessionIDs = createMemo(() => { const item = current() - if (!item) return - if (workflowReceiptStateIsTerminal(item.run.state)) return + if (!item) return [] as string[] const sessionIDs = new Set( [item.run.rootSessionID, ...item.tasks.map((task) => task.sessionID)].filter( (sessionID): sessionID is string => Boolean(sessionID), @@ -199,7 +210,21 @@ export function Workflows() { pendingSessionIDs.push(session.id) } } - const sessionIDList = [...sessionIDs] + return [...sessionIDs] + }) + const currentAuditSessionID = createMemo(() => { + const item = current() + if (!item || item.run.completion?.status !== "auditing") return + return workflowMonitorAuditSessionID({ + rootSessionID: item.run.rootSessionID, + sessions: sync.data.session, + }) + }) + const currentActivity = createMemo(() => { + const item = current() + if (!item) return + if (workflowReceiptStateIsTerminal(item.run.state)) return + const sessionIDList = workflowSessionIDs() const activeTools = sessionIDList.flatMap((sessionID) => (sync.data.message[sessionID] ?? []).flatMap((message) => (sync.data.part[message.id] ?? []).flatMap((part) => { @@ -223,7 +248,8 @@ export function Workflows() { activeTools, pendingPermissions, waitingTasks: item.tasks.filter((task) => task.state === "needs_input").length, - completionStatus: item.run.completion?.status, + completion: item.run.completion, + now: activityNow(), }) }) const currentPermissionMode = createMemo(() => { @@ -241,7 +267,7 @@ export function Workflows() { return item ? receipt(item) : undefined }) const layout = createMemo(() => workflowMonitorLayout(dimensions())) - const monitorRows = createMemo(() => workflowMonitorRows(currentReceipt())) + const monitorRows = createMemo(() => workflowMonitorRows(currentReceipt(), activityNow())) const taskRows = createMemo(() => { const item = current() return item ? workflowMonitorTaskRows(receipt(item)) : [] @@ -274,7 +300,11 @@ export function Workflows() { onMount(() => { const timer = setInterval(() => setRefresh((value) => value + 1), 5_000) const animation = setInterval(() => { - if (items().some((item) => workflowReceiptStateIsAnimated(item.run.state))) { + if ( + items().some( + (item) => workflowReceiptStateIsAnimated(item.run.state) || item.run.completion?.status === "auditing", + ) + ) { setActivityFrame((value) => value + 1) } }, 180) @@ -540,14 +570,14 @@ export function Workflows() { } async function openSession() { - const sessionID = workflowMonitorSessionID(currentReceipt()) + const sessionID = currentAuditSessionID() ?? workflowMonitorSessionID(currentReceipt()) if (!sessionID) { - toast.show({ variant: "info", message: "This workflow has no task transcript yet.", duration: 2500 }) + toast.show({ variant: "info", message: "This workflow has no audit or task transcript yet.", duration: 2500 }) return } const result = await sdk.client.session.get({ sessionID }).catch(() => undefined) if (result?.data) return route.navigate({ type: "session", sessionID }) - toast.show({ variant: "warning", message: "The workflow task transcript was not found.", duration: 3500 }) + toast.show({ variant: "warning", message: "The workflow transcript was not found.", duration: 3500 }) } async function openCreatorSession() { @@ -728,6 +758,13 @@ export function Workflows() { )} + + {(sessionID) => ( + + audit chat {sessionID()} · Enter/o open + + )} + )} @@ -860,6 +897,42 @@ export function Workflows() { )} + + + + COMPLETION AUDIT + + + {workflowCompletionAuditActivity({ + runState: item().run.state, + completion: item().run.completion, + now: activityNow(), + })} + + + {(updatedAt) => ( + + updated {workflowTimeAgo(updatedAt(), activityNow())} + + )} + + + {(sessionID) => ( + + Enter/o opens auditor {sessionID()} + + )} + + + task.blocker)?.blocker if (blocker) return blocker if (snapshot.run.state === "needs_input" || snapshot.run.state === "awaiting_approval") return "Operator input or approval required." if (snapshot.run.state === "blocked") return "Inspect the blocker before resuming." + if (snapshot.run.state === "paused" && snapshot.run.completion?.status === "auditing") { + return "Resume the paused completion audit." + } if (snapshot.run.completion?.status === "candidate") return "Run the fresh completion audit before closing." - if (snapshot.run.completion?.status === "auditing") return "Fresh completion audit is inspecting current evidence." - if (snapshot.run.completion?.status === "rejected") return "Repair the failed completion criteria and regenerate the final evidence." + if (snapshot.run.completion?.status === "auditing") { + const lease = snapshot.run.completion.auditLease + if (lease && lease.expiresAt <= now) return "Completion audit stalled; resume to reclaim the expired audit lease." + if (!lease) return "Completion audit is queued for an auditor." + return "Fresh completion audit is inspecting current evidence." + } + if (snapshot.run.completion?.status === "rejected") + return "Repair the failed completion criteria and regenerate the final evidence." if (snapshot.run.state === "paused") return "Resume when the workflow is ready to continue." if (snapshot.run.state === "completed") return "Review the final artifact." if (snapshot.run.state === "failed") return "Retry the failed task or phase." diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/workflow-view.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/workflow-view.ts index 0f4a7e04..9b83a8cb 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/workflow-view.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/workflow-view.ts @@ -19,13 +19,56 @@ type WorkflowActiveTool = { status: "pending" | "running" } +type WorkflowCompletionActivity = WorkflowReceiptSnapshot["run"]["completion"] + +function workflowDuration(value: number) { + const seconds = Math.max(0, Math.round(value / 1_000)) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainder = seconds % 60 + if (minutes < 60) return remainder ? `${minutes}m ${remainder}s` : `${minutes}m` + const hours = Math.floor(minutes / 60) + const minuteRemainder = minutes % 60 + return minuteRemainder ? `${hours}h ${minuteRemainder}m` : `${hours}h` +} + +export function workflowTimeAgo(timestamp: number | undefined, now = Date.now()) { + if (!timestamp) return "unknown" + return `${workflowDuration(now - timestamp)} ago` +} + +export function workflowCompletionAuditActivity(input: { + runState: string + completion?: WorkflowCompletionActivity + now?: number +}) { + const completion = input.completion + if (!completion) return + if (completion.status === "candidate") return `queued · generation ${completion.generation} · fresh audit pending` + if (completion.status !== "auditing") return + + const attempt = Math.max(1, completion.auditAttempts) + if (input.runState === "paused") return `paused · audit attempt ${attempt}` + if (input.runState === "needs_input") return `waiting for input · audit attempt ${attempt}` + if (input.runState === "awaiting_approval") return `waiting for approval · audit attempt ${attempt}` + + const lease = completion.auditLease + if (!lease) return `queued · audit attempt ${attempt} · waiting for auditor` + const now = input.now ?? Date.now() + if (lease.expiresAt <= now) { + return `stalled · audit attempt ${attempt} · lease expired ${workflowDuration(now - lease.expiresAt)} ago` + } + return `running · audit attempt ${attempt} · lease ${workflowDuration(lease.expiresAt - now)} left` +} + export function workflowCurrentActivity(input: { runState: string statuses: readonly WorkflowActivityStatus[] activeTools?: readonly WorkflowActiveTool[] pendingPermissions?: number waitingTasks?: number - completionStatus?: string + completion?: WorkflowCompletionActivity + now?: number }) { const pendingPermissions = input.pendingPermissions ?? 0 const runningTool = input.activeTools?.findLast((tool) => tool.status === "running" && tool.tool !== "task") @@ -42,8 +85,6 @@ export function workflowCurrentActivity(input: { if (waitingTasks > 0) return `${waitingTasks} task${waitingTasks === 1 ? "" : "s"} waiting for input` if (input.runState === "awaiting_approval") return "Workflow approval required" if (input.runState === "needs_input") return "Workflow waiting for input" - if (input.completionStatus === "candidate") return "Completion candidate waiting for a fresh audit" - if (input.completionStatus === "auditing") return "Auditing completion evidence" const compacting = input.statuses.find((status) => status.type === "busy" && status.kind === "compaction") if (compacting) return compacting.message || "AI is compacting context" @@ -66,6 +107,11 @@ export function workflowCurrentActivity(input: { if (input.statuses.some((status) => status.type === "busy" && status.kind === "mflow-wait")) { return "Working with file locks" } + return workflowCompletionAuditActivity({ + runState: input.runState, + completion: input.completion, + now: input.now, + }) } export function workflowRequestErrorText(error: unknown) { @@ -101,13 +147,27 @@ export function workflowMonitorTaskCounts(snapshot?: Pick { +export function workflowMonitorRows(snapshot?: WorkflowReceiptSnapshot, now = Date.now()): Array<[string, string]> { if (!snapshot) return [] as Array<[string, string]> const counts = workflowMonitorTaskCounts(snapshot) - const elapsed = workflowReceiptElapsed(snapshot) + const elapsed = workflowReceiptElapsed(snapshot, now) + const audit = workflowCompletionAuditActivity({ + runState: snapshot.run.state, + completion: snapshot.run.completion, + now, + }) return [ ["state", workflowReceiptStateLabel(snapshot.run.state)], - ["completion", snapshot.run.completion ? `${snapshot.run.completion.status} · gen ${snapshot.run.completion.generation} · ${snapshot.run.completion.auditAttempts} audit${snapshot.run.completion.auditAttempts === 1 ? "" : "s"}` : "legacy same-run"], + [ + "completion", + snapshot.run.completion + ? `${snapshot.run.completion.status} · gen ${snapshot.run.completion.generation} · ${snapshot.run.completion.auditAttempts} audit${snapshot.run.completion.auditAttempts === 1 ? "" : "s"}` + : "legacy same-run", + ], + ...(audit ? [["audit", audit] as [string, string]] : []), + ...(snapshot.run.completion?.updatedAt + ? [["audit update", workflowTimeAgo(snapshot.run.completion.updatedAt, now)] as [string, string]] + : []), ["progress", workflowReceiptProgress(snapshot)], [ "phases", @@ -116,7 +176,7 @@ export function workflowMonitorRows(snapshot?: WorkflowReceiptSnapshot): Array<[ ["active", `${counts.working} working · ${counts.queued} queued`], ["blocked", `${counts.blocked} blocked · ${counts.failed} failed · ${counts.needsInput} input`], ["elapsed", `${Math.round(elapsed / 1000)}s`], - ["next", workflowReceiptNextAction(snapshot)], + ["next", workflowReceiptNextAction(snapshot, now)], ] } @@ -196,6 +256,34 @@ export function workflowMonitorSessionID(snapshot?: WorkflowReceiptSnapshot) { ) } +export function workflowMonitorAuditSessionID(input: { + rootSessionID?: string + sessions: readonly { + id: string + parentID?: string + title: string + time?: { created?: number; updated?: number } + }[] +}) { + if (!input.rootSessionID) return + const descendants = new Set([input.rootSessionID]) + let changed = true + while (changed) { + changed = false + for (const session of input.sessions) { + if (!session.parentID || !descendants.has(session.parentID) || descendants.has(session.id)) continue + descendants.add(session.id) + changed = true + } + } + return input.sessions + .filter((session) => descendants.has(session.id) && session.title.startsWith("Completion audit:")) + .toSorted( + (left, right) => + (right.time?.updated ?? right.time?.created ?? 0) - (left.time?.updated ?? left.time?.created ?? 0), + )[0]?.id +} + export function workflowTaskSessionContext(input: { sessionID: string workflows: readonly { diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/loop.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/loop.ts index 5f659467..bf1b2a20 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/loop.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/loop.ts @@ -1,10 +1,17 @@ import { externalSignalRateLimit, LoopID, LoopWorkflow } from "@/session/loop" import { LoopRunner } from "@/session/loop-runner" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" +import { WorkflowRunner } from "@/session/workflow-runner" +import { WorkflowService } from "@/session/workflow-service" +import { InstanceState } from "@/effect/instance-state" +import { loopServiceArgsFromConfig, loopServiceStart } from "@/mend/runtime/loop-service" import { ServerAuth } from "@/server/auth" import { Effect, Schema } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Flag } from "@mendcode/core/flag/flag" import { timingSafeEqual } from "crypto" +import { LoopDraftBody } from "../../loop-draft-schema" const LoopParams = Schema.Struct({ loopID: LoopID, @@ -41,32 +48,30 @@ function requestGlobalPage(request: HttpServerRequest.HttpServerRequest) { } function readReasonBody(request: HttpServerRequest.HttpServerRequest) { - return Effect.promise(async () => { - const body = await webSource(request)?.json().catch(() => ({})) - if (!body || typeof body !== "object") return undefined - const reason = (body as { reason?: unknown }).reason - return typeof reason === "string" ? reason : undefined - }) + return readObjectBody(request).pipe( + Effect.map((body) => { + const reason = (body as { reason?: unknown }).reason + return typeof reason === "string" ? reason : undefined + }), + ) } function readAgentBody(request: HttpServerRequest.HttpServerRequest) { - return Effect.promise(async () => { - const body = await webSource(request)?.json().catch(() => ({})) - if (!body || typeof body !== "object") return {} - const agent = (body as { agent?: unknown }).agent - const reason = (body as { reason?: unknown }).reason - return { - agent: typeof agent === "string" ? agent : undefined, - reason: typeof reason === "string" ? reason : undefined, - } - }) + return readObjectBody(request).pipe( + Effect.map((body) => ({ + agent: typeof body.agent === "string" ? body.agent : undefined, + reason: typeof body.reason === "string" ? body.reason : undefined, + })), + ) } function readObjectBody(request: HttpServerRequest.HttpServerRequest) { - return Effect.promise(async () => { - const body = await webSource(request)?.json().catch(() => ({})) - return body && typeof body === "object" && !Array.isArray(body) ? body as Record : {} - }) + return request.json.pipe( + Effect.catch(() => Effect.succeed(null)), + Effect.map((body) => + body && typeof body === "object" && !Array.isArray(body) ? (body as Record) : {}, + ), + ) } function authenticationRequired() { @@ -92,6 +97,21 @@ export const loopRoute = HttpRouter.use((router) => Effect.gen(function* () { const loop = yield* LoopWorkflow.Service const runner = yield* LoopRunner.Service + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const workflows = yield* WorkflowService.Service + const workflowRunner = yield* WorkflowRunner.Service + + const runOne = (input: LoopRunner.RunOneInput) => + runner + .runOne(input) + .pipe( + Effect.provideService(LoopWorkflow.Service, loop), + Effect.provideService(Session.Service, sessions), + Effect.provideService(SessionPrompt.Service, prompt), + Effect.provideService(WorkflowService.Service, workflows), + Effect.provideService(WorkflowRunner.Service, workflowRunner), + ) yield* router.add( "GET", @@ -130,6 +150,19 @@ export const loopRoute = HttpRouter.use((router) => }), ) + yield* router.add( + "POST", + "/loop/draft", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const parsed = LoopDraftBody.safeParse(yield* readObjectBody(request)) + if (!parsed.success) { + return HttpServerResponse.jsonUnsafe({ error: "name and objective are required" }, { status: 400 }) + } + return HttpServerResponse.jsonUnsafe(yield* loop.createDraft(parsed.data)) + }), + ) + yield* router.add( "POST", "/loop/signal", @@ -194,11 +227,35 @@ export const loopRoute = HttpRouter.use((router) => if (action === "pause") return HttpServerResponse.jsonUnsafe(yield* loop.pause({ id: params.loopID, reason })) if (action === "resume") return HttpServerResponse.jsonUnsafe(yield* loop.resume({ id: params.loopID, reason })) if (action === "run-once") { - return HttpServerResponse.jsonUnsafe(yield* runner.runOne({ id: params.loopID, execute: true, reason, trigger: "run-once" })) + return HttpServerResponse.jsonUnsafe( + yield* runOne({ id: params.loopID, execute: true, reason, trigger: "run-once" }), + ) } return HttpServerResponse.jsonUnsafe(yield* loop.stop({ id: params.loopID, reason })) }) + yield* router.add( + "POST", + "/loop/:loopID/activate", + Effect.gen(function* () { + const params = yield* HttpRouter.schemaPathParams(LoopParams) + const request = yield* HttpServerRequest.HttpServerRequest + const body = yield* readObjectBody(request) + if (body.reason !== undefined && typeof body.reason !== "string") { + return HttpServerResponse.jsonUnsafe({ error: "reason must be a string" }, { status: 400 }) + } + if (body.ensureService !== undefined && typeof body.ensureService !== "boolean") { + return HttpServerResponse.jsonUnsafe({ error: "ensureService must be a boolean" }, { status: 400 }) + } + const active = yield* loop.activate({ id: params.loopID, reason: body.reason as string | undefined }) + if (body.ensureService !== false) { + const ctx = yield* InstanceState.context + yield* Effect.promise(() => loopServiceStart(loopServiceArgsFromConfig(ctx.directory)).catch(() => undefined)) + } + return HttpServerResponse.jsonUnsafe(active) + }), + ) + yield* router.add("POST", "/loop/:loopID/pause", control("pause")) yield* router.add("POST", "/loop/:loopID/resume", control("resume")) yield* router.add( diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/loop-draft-schema.ts b/src/mendcode/packages/opencode/src/server/routes/instance/loop-draft-schema.ts new file mode 100644 index 00000000..47542ed3 --- /dev/null +++ b/src/mendcode/packages/opencode/src/server/routes/instance/loop-draft-schema.ts @@ -0,0 +1,108 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionID } from "@/session/schema" +import z from "zod" + +const nonNegativeInteger = z.number().int().nonnegative() + +export const LoopDraftBody = z.object({ + name: z.string().trim().min(1), + objective: z.string().trim().min(1), + source: z.enum(["converted-session", "objective", "template", "manual"]).optional(), + workspaceID: WorkspaceID.zod.optional(), + ownerSessionID: SessionID.zod.optional(), + templateID: z.string().optional(), + trigger: z + .object({ + mode: z.enum(["manual", "interval", "daily", "adaptive", "external-signal", "self-paced"]).optional(), + intervalMs: nonNegativeInteger.optional(), + dailyAt: z.string().optional(), + timezone: z.string().optional(), + }) + .optional(), + workflow: z + .object({ + revisionID: z.string().trim().min(1).optional(), + definitionID: z.string().trim().min(1).optional(), + overlapKey: z.string().trim().min(1).optional(), + }) + .optional(), + budgetMode: z.enum(["fixed", "max-goal", "unbounded-monitor"]).optional(), + completionCriteria: z.array(z.string()).optional(), + successChecks: z.array(z.string()).optional(), + validationChecks: z + .array(z.object({ id: z.string(), command: z.string(), timeoutMs: nonNegativeInteger.optional() })) + .optional(), + strategy: z + .object({ + targetTurns: nonNegativeInteger.optional(), + reserveTurns: nonNegativeInteger.optional(), + notifyOwnerOnComplete: z.boolean().optional(), + }) + .optional(), + stopWhen: z.array(z.string()).optional(), + gates: z.array(z.string()).optional(), + model: z + .object({ providerID: z.string().min(1), modelID: z.string().min(1), variant: z.string().optional() }) + .optional(), + agent: z.string().optional(), + evaluation: z + .object({ + mode: z.enum(["legacy", "deterministic", "independent"]).optional(), + confirmation: z.enum(["same-run", "next-run"]).optional(), + evaluatorAgent: z.string().optional(), + requireIndependentForCompletion: z.boolean().optional(), + allowWorkerSelfComplete: z.boolean().optional(), + maxEvaluatorRetries: nonNegativeInteger.optional(), + }) + .optional(), + rubric: z + .object({ + name: z.string().optional(), + passThreshold: z.number().optional(), + criteria: z + .array( + z.object({ + id: z.string(), + description: z.string(), + weight: z.number().optional(), + minScore: z.number().optional(), + evidenceRequired: z.array(z.string()).optional(), + }), + ) + .optional(), + mandatoryBlockers: z.array(z.string()).optional(), + }) + .optional(), + workspace: z + .object({ mode: z.enum(["read-only", "in-place", "per-loop-worktree", "per-run-worktree"]).optional() }) + .optional(), + costBudget: z + .object({ maxCost: z.number().nonnegative().optional(), maxTokens: nonNegativeInteger.optional() }) + .optional(), + approvalPolicy: z + .object({ requireApprovalFor: z.array(z.string()).optional(), approvedActions: z.array(z.string()).optional() }) + .optional(), + memory: z + .object({ + enabled: z.boolean().optional(), + sections: z.array(z.enum(["tried", "verified", "open", "decisions", "rejected"])).optional(), + }) + .optional(), + retention: z + .object({ + maxArtifacts: nonNegativeInteger.optional(), + maxAgeMs: nonNegativeInteger.optional(), + maxBytes: nonNegativeInteger.optional(), + }) + .optional(), + policy: z + .object({ + maxTurns: z.number().int().positive().optional(), + maxRuntimeMs: nonNegativeInteger.optional(), + maxChildren: nonNegativeInteger.optional(), + maxDepth: nonNegativeInteger.optional(), + requireApprovalFor: z.array(z.string()).optional(), + approvedActions: z.array(z.string()).optional(), + }) + .optional(), +}) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/loop.ts b/src/mendcode/packages/opencode/src/server/routes/instance/loop.ts index f4294528..b1393591 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/loop.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/loop.ts @@ -2,13 +2,13 @@ import { InstanceState } from "@/effect/instance-state" import { loopServiceArgsFromConfig, loopServiceStart } from "@/mend/runtime/loop-service" import { externalSignalRateLimit, LoopID, LoopWorkflow } from "@/session/loop" import { LoopRunner } from "@/session/loop-runner" -import { SessionID } from "@/session/schema" import { lazy } from "@/util/lazy" import { Effect } from "effect" import { Hono } from "hono" import z from "zod" import { jsonRequest, runRequest } from "./trace" import { Flag } from "@mendcode/core/flag/flag" +import { LoopDraftBody } from "./loop-draft-schema" const ReasonBody = z.object({ reason: z.string().optional(), @@ -41,40 +41,6 @@ function securedOperator(c: { json: (value: unknown, status?: 401) => Response } return c.json({ error: "Loop signal and override endpoints require configured server authentication." }, 401) } -const DraftBody = z.object({ - name: z.string().min(1), - objective: z.string().min(1), - source: z.enum(["converted-session", "objective", "template", "manual"]).optional(), - ownerSessionID: SessionID.zod.optional(), - templateID: z.string().optional(), - trigger: z - .object({ - mode: z.enum(["manual", "interval", "daily", "adaptive", "external-signal", "self-paced"]).optional(), - intervalMs: z.number().int().nonnegative().optional(), - dailyAt: z.string().optional(), - timezone: z.string().optional(), - }) - .optional(), - workflow: z - .object({ - revisionID: z.string().trim().min(1).optional(), - definitionID: z.string().trim().min(1).optional(), - overlapKey: z.string().trim().min(1).optional(), - }) - .optional(), - stopWhen: z.array(z.string()).optional(), - gates: z.array(z.string()).optional(), - policy: z - .object({ - maxTurns: z.number().int().positive().optional(), - maxRuntimeMs: z.number().int().nonnegative().optional(), - maxChildren: z.number().int().nonnegative().optional(), - maxDepth: z.number().int().nonnegative().optional(), - requireApprovalFor: z.array(z.string()).optional(), - }) - .optional(), -}) - async function readJson(c: { req: { json: () => Promise } }, schema: z.ZodType) { return schema.parse(await c.req.json().catch(() => ({}))) } @@ -131,7 +97,7 @@ export const LoopRoutes = lazy(() => }), ) .post("/draft", async (c) => { - const body = await readJson(c, DraftBody) + const body = await readJson(c, LoopDraftBody) return jsonRequest("LoopRoutes.draft", c, function* () { const loop = yield* LoopWorkflow.Service return yield* loop.createDraft(body) diff --git a/src/mendcode/packages/opencode/src/session/loop-runner.ts b/src/mendcode/packages/opencode/src/session/loop-runner.ts index 5b6f88dd..5c53b28c 100644 --- a/src/mendcode/packages/opencode/src/session/loop-runner.ts +++ b/src/mendcode/packages/opencode/src/session/loop-runner.ts @@ -6,6 +6,7 @@ import * as MessageV2 from "@/session/message-v2" import { Session } from "@/session/session" import { ModelID, ProviderID } from "@/provider/schema" import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "@/project/instance-store" import { WorkflowRunner } from "@/session/workflow-runner" import { WorkflowService } from "@/session/workflow-service" import { fingerprintWorkspace } from "@/session/completion-auditor" @@ -19,7 +20,7 @@ import { completionValidationCommandAllowed, runCompletionValidationCommand } fr export const TickResult = Schema.Struct({ workflowID: LoopWorkflow.LoopID, runID: Schema.optional(LoopWorkflow.RunID), - state: Schema.Literals(["completed", "failed", "blocked", "needs_input", "stopped", "skipped"]), + state: Schema.Literals(["completed", "retrying", "failed", "blocked", "needs_input", "stopped", "skipped"]), summary: Schema.String, }) export type TickResult = Types.DeepMutable> @@ -136,18 +137,28 @@ function checkpointGuidance(workflow: LoopWorkflow.Info) { return "Work autonomously toward completing the objective, not toward consuming every iteration. If the goal is already complete, verify it and report status: complete instead of making more changes." } -function workspacePolicyPrompt(workflow: LoopWorkflow.Info) { +function workspacePolicyPrompt(workflow: LoopWorkflow.Info, run?: LoopWorkflow.RunInfo) { const mode = workflow.spec.workspace?.mode ?? "in-place" if (mode === "read-only") return "Workspace policy: read-only. Inspect and report only; do not edit files or run mutating shell commands." - if (mode === "per-loop-worktree") - return "Workspace policy: per-loop-worktree. Use the assigned loop workspace metadata when available; do not create, promote, or clean worktrees yourself unless explicitly instructed." - if (mode === "per-run-worktree") - return "Workspace policy: per-run-worktree. Use the assigned run workspace metadata when available; do not create, promote, or clean worktrees yourself unless explicitly instructed." + if (mode === "per-loop-worktree" || mode === "per-run-worktree") { + const lease = run?.workspaceLease + if (lease?.state === "active") { + return [ + `Workspace policy: ${mode}.`, + `Authoritative workspace directory: ${lease.path}`, + `Authoritative workspace branch: ${lease.branch ?? "unknown"}`, + "All file reads, edits, shell commands, validation, and git inspection for this iteration must run in that authoritative workspace.", + "The parent/original checkout may not contain these unpromoted changes. Do not conclude that work is missing by inspecting another checkout.", + "Do not create, promote, clean, or remove this worktree yourself unless explicitly instructed.", + ].join("\n") + } + return `Workspace policy: ${mode}. No active isolated workspace lease is available; stay in the current workspace and report the lease failure instead of inventing or searching for another worktree.` + } return "Workspace policy: in-place. Work in the current project workspace and keep changes minimal and auditable." } -function iterationPrompt(workflow: LoopWorkflow.Info, reason?: string) { +function iterationPrompt(workflow: LoopWorkflow.Info, run: LoopWorkflow.RunInfo, reason?: string) { const turn = (workflow.metrics.turns ?? 0) + 1 const maxTurns = workflow.policy.maxTurns ?? "unlimited" const next = workflow.nextWakeup ? new Date(workflow.nextWakeup).toISOString() : "now" @@ -166,7 +177,7 @@ function iterationPrompt(workflow: LoopWorkflow.Info, reason?: string) { "", `Objective: ${workflow.objective}`, reason?.trim() ? `Operator-requested run reason: ${reason.trim()}` : undefined, - workspacePolicyPrompt(workflow), + workspacePolicyPrompt(workflow, run), `Budget mode: ${budgetSemantics(workflow)}`, `Remaining iteration budget after this run starts: ${remaining}`, strategy?.targetTurns @@ -220,9 +231,9 @@ function iterationPrompt(workflow: LoopWorkflow.Info, reason?: string) { .join("\n") } -function reportOnlyPrompt(workflow: LoopWorkflow.Info, reason?: string) { +function reportOnlyPrompt(workflow: LoopWorkflow.Info, run: LoopWorkflow.RunInfo, reason?: string) { return [ - iterationPrompt(workflow, reason), + iterationPrompt(workflow, run, reason), "", "REPORT-ONLY MODE:", "- Do not edit files.", @@ -357,16 +368,48 @@ function incompleteWorkerFailure(message: MessageV2.WithParts): { } } return { - reason: "Loop worker ended without a terminal finish; retrying the iteration.", + reason: "Loop worker ended without a terminal finish.", failureClass: "transient", } } return { - reason: "Loop worker finished without a parseable LOOP_CHECKPOINT; retrying the iteration.", + reason: "Loop worker finished without a parseable LOOP_CHECKPOINT.", failureClass: "transient", } } +function failedRunResult(input: { + workflow: LoopWorkflow.Info + run: LoopWorkflow.RunInfo + reason: string +}): TickResult { + const retryAt = input.run.retry?.nextWakeup ?? input.workflow.nextWakeup + if (input.run.retry && retryAt) { + return { + workflowID: input.workflow.id, + runID: input.run.id, + state: "retrying", + summary: `Loop run retry scheduled for ${new Date(retryAt).toISOString()}. ${input.reason}`, + } + } + const state = + input.workflow.state === "blocked" || + input.workflow.state === "needs_input" || + input.workflow.state === "stopped" || + input.workflow.state === "failed" + ? input.workflow.state + : "failed" + return { + workflowID: input.workflow.id, + runID: input.run.id, + state, + summary: + state === "failed" + ? `Loop run failed and will not retry automatically. ${input.reason}` + : (input.run.evaluatorReason ?? input.workflow.evaluatorReason ?? input.reason), + } +} + function mergeUsage(usages: Array): LoopWorkflow.Usage | undefined { const items = usages.filter((item): item is LoopWorkflow.Usage => Boolean(item)) if (!items.length) return undefined @@ -999,15 +1042,35 @@ function auditReceipt(input: { } } -function parentCompletionPrompt(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint, runID: LoopWorkflow.RunID) { +function ownerWorkspacePrompt(run: LoopWorkflow.RunInfo) { + const lease = run.workspaceLease + if (!lease) return [] + if (lease.state !== "active") { + return [ + `Workspace isolation: ${lease.state}`, + `Workspace fallback: ${lease.path}`, + lease.error ? `Workspace error: ${lease.error}` : undefined, + ].filter((line): line is string => Boolean(line)) + } + return [ + `Authoritative workspace: ${lease.path}`, + `Authoritative branch: ${lease.branch ?? "unknown"}`, + `Workspace mode: ${lease.mode}`, + `Inspect changes there before judging the result: git -C ${JSON.stringify(lease.path)} status --short`, + "Unpromoted loop changes may be absent from the parent/original checkout; that does not mean the loop lost them.", + ] +} + +function parentCompletionPrompt(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint, run: LoopWorkflow.RunInfo) { return [ `Loop workflow completed: ${workflow.name}`, "", `Workflow: ${workflow.id}`, `Loop chat: ${workflow.rootSessionID ?? "none"}`, - `Run: ${runID}`, + `Run: ${run.id}`, `Iterations used: ${workflow.metrics.turns ?? 0}/${workflow.policy.maxTurns ?? "unlimited"}`, `Goal: ${workflow.objective}`, + ...ownerWorkspacePrompt(run), "", `Summary: ${checkpoint.summary ?? workflow.evaluatorReason ?? "Loop reported completion."}`, checkpoint.evidence?.length @@ -1021,6 +1084,36 @@ function parentCompletionPrompt(workflow: LoopWorkflow.Info, checkpoint: LoopChe .join("\n") } +function shouldNotifyOwner(workflow: LoopWorkflow.Info) { + if (!workflow.ownerSessionID || workflow.ownerSessionID === workflow.rootSessionID) return false + if (workflow.state === "completed") return workflow.spec.strategy?.notifyOwnerOnComplete === true + return ( + workflow.state === "failed" || + workflow.state === "blocked" || + workflow.state === "needs_input" || + workflow.state === "stopped" + ) +} + +function parentStatusPrompt(workflow: LoopWorkflow.Info, run: LoopWorkflow.RunInfo, summary: string) { + return [ + '', + "This is an internal runtime notification, not a user request.", + `Loop workflow: ${workflow.name}`, + `Workflow: ${workflow.id}`, + `Loop chat: ${workflow.rootSessionID ?? "none"}`, + `Run: ${run.id}`, + `State: ${workflow.state}`, + `Phase: ${workflow.phase}`, + `Iterations completed: ${workflow.metrics.turns ?? 0}/${workflow.policy.maxTurns ?? "unlimited"}`, + `Summary: ${summary}`, + ...ownerWorkspacePrompt(run), + "Treat this persisted workflow state as authoritative. Tell the user clearly when the loop needs input, is blocked, stopped, or failed; do not claim it is still working after a terminal state.", + "Do not create a replacement loop, push, merge, release, or run broad changes unless the user explicitly asks.", + "", + ].join("\n") +} + export const layer = Layer.effect( Service, Effect.sync(() => { @@ -1029,7 +1122,52 @@ export const layer = Layer.effect( const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const instance = yield* InstanceState.context + const instances = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service)) const id = input.id + const inRunWorkspace = (run: LoopWorkflow.RunInfo, effect: Effect.Effect) => { + const lease = run.workspaceLease + if (lease?.state !== "active" || lease.path === instance.directory) return effect + if (!instances) return Effect.fail(new Error("Instance store unavailable for an isolated loop workspace")) + return instances.provide( + { + directory: lease.path, + worktree: lease.path, + project: instance.project, + }, + effect, + ) + } + const notifyOwner = (workflow: LoopWorkflow.Info, run: LoopWorkflow.RunInfo, summary: string) => { + if (!shouldNotifyOwner(workflow) || !workflow.ownerSessionID) return Effect.void + const text = + workflow.state === "completed" + ? parentCompletionPrompt(workflow, { summary }, run) + : parentStatusPrompt(workflow, run, summary) + return prompt + .prompt({ + sessionID: workflow.ownerSessionID, + agent: workflow.spec.agent, + model: promptModel(workflow), + variant: workflow.spec.model?.variant, + parts: [ + { + type: "text", + text, + synthetic: true, + metadata: { + kind: "loop_owner_notification", + workflowID: workflow.id, + runID: run.id, + state: workflow.state, + workspacePath: run.workspaceLease?.path, + workspaceBranch: run.workspaceLease?.branch, + workspaceMode: run.workspaceLease?.mode, + }, + }, + ], + }) + .pipe(Effect.ignore) + } const before = yield* workflow.get(id, input.now) if (typeof before.policy.maxTurns === "number" && (before.metrics.turns ?? 0) >= before.policy.maxTurns) { return { @@ -1155,41 +1293,44 @@ export const layer = Layer.effect( title: `Loop completion audit: ${current.name}`, }) const evaluatorStarted = Date.now() - const evaluatorResult = yield* prompt - .prompt({ - sessionID: evaluatorSession.id, - agent: current.spec.evaluation?.evaluatorAgent ?? current.spec.agent, - model: promptModel(current), - variant: current.spec.model?.variant, - tools: loopIterationTools(true), - parts: [ - { - type: "text", - text: [ - judgmentPrompt(current, checkpoint, preJudgeGates), - "", - `This is a fresh terminal audit iteration for candidate generation ${candidate.generation}.`, - `Inspect the current workspace at ${directory}; do not rely only on the worker summary.`, - "Verify every completion criterion against current files and recorded deterministic gates.", - ].join("\n"), - }, - ], - }) - .pipe( - Effect.map((message) => ({ message, judgment: parseJudgment(assistantText(message)) })), - Effect.catchCause((cause) => { - const error = errorMessage(Cause.squash(cause)) - return Effect.succeed({ - judgment: { - status: "uncertain" as const, - summary: `Evaluator failed: ${error}`, - recommendedNextAction: "retry", - confidence: "low", - failureClass: classifyEvaluatorFailure(error), + const evaluatorResult = yield* inRunWorkspace( + validationRun, + prompt + .prompt({ + sessionID: evaluatorSession.id, + agent: current.spec.evaluation?.evaluatorAgent ?? current.spec.agent, + model: promptModel(current), + variant: current.spec.model?.variant, + tools: loopIterationTools(true), + parts: [ + { + type: "text", + text: [ + judgmentPrompt(current, checkpoint, preJudgeGates), + "", + `This is a fresh terminal audit iteration for candidate generation ${candidate.generation}.`, + `Inspect the current workspace at ${directory}; do not rely only on the worker summary.`, + "Verify every completion criterion against current files and recorded deterministic gates.", + ].join("\n"), }, - }) - }), - ) + ], + }) + .pipe( + Effect.map((message) => ({ message, judgment: parseJudgment(assistantText(message)) })), + Effect.catchCause((cause) => { + const error = errorMessage(Cause.squash(cause)) + return Effect.succeed({ + judgment: { + status: "uncertain" as const, + summary: `Evaluator failed: ${error}`, + recommendedNextAction: "retry", + confidence: "low", + failureClass: classifyEvaluatorFailure(error), + }, + }) + }), + ), + ) const evaluatorMessage = "message" in evaluatorResult ? evaluatorResult.message : undefined evaluatorUsage = usageFromMessage(evaluatorMessage, current, evaluatorStarted) judgment = evaluatorResult.judgment @@ -1277,27 +1418,7 @@ export const layer = Layer.effect( completion: { candidate, receipt }, }) const after = yield* workflow.get(id) - if ( - after.state === "completed" && - after.spec.strategy?.notifyOwnerOnComplete === true && - after.ownerSessionID && - after.ownerSessionID !== after.rootSessionID - ) { - yield* prompt - .prompt({ - sessionID: after.ownerSessionID, - agent: after.spec.agent, - model: promptModel(after), - variant: after.spec.model?.variant, - parts: [ - { - type: "text", - text: parentCompletionPrompt(after, { ...checkpoint, summary: receipt.summary }, completed.id), - }, - ], - }) - .pipe(Effect.ignore) - } + yield* notifyOwner(after, completed, receipt.summary) return { workflowID: id, runID: completed.id, @@ -1317,19 +1438,10 @@ export const layer = Layer.effect( if (!workflowService || !workflowRunner) { const error = "Loop workflow adapter services are unavailable." const failed = yield* workflow.failRun({ id, runID: run.id, error, failureClass: "environment", now }) - return { - workflowID: id, - runID: failed.id, - state: - failed.state === "blocked" - ? "blocked" - : failed.state === "needs_input" - ? "needs_input" - : failed.state === "stopped" - ? "stopped" - : "failed", - summary: failed.evaluatorReason ?? error, - } satisfies TickResult + const after = yield* workflow.get(id) + const outcome = failedRunResult({ workflow: after, run: failed, reason: error }) + yield* notifyOwner(after, failed, outcome.summary) + return outcome } const reference = current.spec.workflow const startedExit = yield* Effect.exit( @@ -1345,19 +1457,10 @@ export const layer = Layer.effect( if (startedExit._tag === "Failure") { const error = errorMessage(Cause.squash(startedExit.cause)) const failed = yield* workflow.failRun({ id, runID: run.id, error, failureClass: "policy", now }) - return { - workflowID: id, - runID: failed.id, - state: - failed.state === "blocked" - ? "blocked" - : failed.state === "needs_input" - ? "needs_input" - : failed.state === "stopped" - ? "stopped" - : "failed", - summary: failed.evaluatorReason ?? error, - } satisfies TickResult + const after = yield* workflow.get(id) + const outcome = failedRunResult({ workflow: after, run: failed, reason: error }) + yield* notifyOwner(after, failed, outcome.summary) + return outcome } const startedWorkflow = startedExit.value if (startedWorkflow.run.loopRunID !== run.id) { @@ -1387,37 +1490,19 @@ export const layer = Layer.effect( .stop({ runID: startedWorkflow.run.id, reason: error, actor: "loop-adapter" }) .pipe(Effect.catchCause(() => Effect.void)) const failed = yield* workflow.failRun({ id, runID: run.id, error, failureClass: "environment", now }) - return { - workflowID: id, - runID: failed.id, - state: - failed.state === "blocked" - ? "blocked" - : failed.state === "needs_input" - ? "needs_input" - : failed.state === "stopped" - ? "stopped" - : "failed", - summary: failed.evaluatorReason ?? error, - } satisfies TickResult + const after = yield* workflow.get(id) + const outcome = failedRunResult({ workflow: after, run: failed, reason: error }) + yield* notifyOwner(after, failed, outcome.summary) + return outcome } const finishedWorkflow = yield* workflowService.show(startedWorkflow.run.id) if (finishedWorkflow.run.state === "failed") { const error = finishedWorkflow.tasks.find((task) => task.blocker)?.blocker ?? "Referenced workflow failed." const failed = yield* workflow.failRun({ id, runID: run.id, error, failureClass: "terminal", now }) - return { - workflowID: id, - runID: failed.id, - state: - failed.state === "blocked" - ? "blocked" - : failed.state === "needs_input" - ? "needs_input" - : failed.state === "stopped" - ? "stopped" - : "failed", - summary: failed.evaluatorReason ?? error, - } satisfies TickResult + const after = yield* workflow.get(id) + const outcome = failedRunResult({ workflow: after, run: failed, reason: error }) + yield* notifyOwner(after, failed, outcome.summary) + return outcome } const goalStatus = finishedWorkflow.run.state === "completed" @@ -1456,6 +1541,8 @@ export const layer = Layer.effect( usage, }) const after = yield* workflow.get(id) + const summary = after.evaluatorReason ?? completed.evaluatorReason ?? "Referenced workflow run completed." + yield* notifyOwner(after, completed, summary) return { workflowID: id, runID: completed.id, @@ -1466,14 +1553,15 @@ export const layer = Layer.effect( after.state === "failed" ? after.state : "completed", - summary: after.evaluatorReason ?? completed.evaluatorReason ?? "Referenced workflow run completed.", + summary, } satisfies TickResult } const reportOnly = workflowIsReportOnly(current) || (input.reportOnly === true && !workflowExplicitlyAllowsEdits(current)) const runStarted = Date.now() - const result = yield* prompt - .prompt({ + const result = yield* inRunWorkspace( + run, + prompt.prompt({ sessionID: current.rootSessionID, agent: current.spec.agent, model: promptModel(current), @@ -1482,23 +1570,20 @@ export const layer = Layer.effect( parts: [ { type: "text", - text: reportOnly ? reportOnlyPrompt(current, input.reason) : iterationPrompt(current, input.reason), + text: reportOnly + ? reportOnlyPrompt(current, run, input.reason) + : iterationPrompt(current, run, input.reason), }, ], - }) - .pipe(Effect.exit) + }), + ).pipe(Effect.exit) if (result._tag === "Failure") { const message = errorMessage(Cause.squash(result.cause)) const failed = yield* workflow.failRun({ id, runID: run.id, error: message, now }) const after = yield* workflow.get(id) - return { - workflowID: id, - runID: failed.id, - state: "failed", - summary: failed.retry - ? `Loop run failed with ${failed.failureClass ?? "retryable"}; retry scheduled for ${new Date(failed.retry.nextWakeup ?? after.nextWakeup ?? Date.now()).toISOString()}. ${message}` - : (failed.evaluatorReason ?? after.evaluatorReason ?? message), - } satisfies TickResult + const outcome = failedRunResult({ workflow: after, run: failed, reason: message }) + yield* notifyOwner(after, failed, outcome.summary) + return outcome } if (result.value.info.role !== "assistant" || !result.value.info.finish) { const incomplete = incompleteWorkerFailure(result.value) @@ -1509,14 +1594,10 @@ export const layer = Layer.effect( failureClass: incomplete.failureClass, now, }) - return { - workflowID: id, - runID: failed.id, - state: failed.state === "needs_input" ? ("needs_input" as const) : ("failed" as const), - summary: failed.retry - ? `Loop run retry scheduled for ${new Date(failed.retry.nextWakeup ?? Date.now()).toISOString()}. ${incomplete.reason}` - : (failed.evaluatorReason ?? incomplete.reason), - } satisfies TickResult + const after = yield* workflow.get(id) + const outcome = failedRunResult({ workflow: after, run: failed, reason: incomplete.reason }) + yield* notifyOwner(after, failed, outcome.summary) + return outcome } const checkpoint = parseCheckpoint(assistantText(result.value)) if (!checkpoint.status) { @@ -1528,14 +1609,10 @@ export const layer = Layer.effect( failureClass: incomplete.failureClass, now, }) - return { - workflowID: id, - runID: failed.id, - state: "failed" as const, - summary: failed.retry - ? `Loop run retry scheduled for ${new Date(failed.retry.nextWakeup ?? Date.now()).toISOString()}. ${incomplete.reason}` - : (failed.evaluatorReason ?? incomplete.reason), - } satisfies TickResult + const after = yield* workflow.get(id) + const outcome = failedRunResult({ workflow: after, run: failed, reason: incomplete.reason }) + yield* notifyOwner(after, failed, outcome.summary) + return outcome } const validationGates = yield* executeValidationChecks(workflow, current, run, checkpoint, instance.directory) const preJudgeGates = [checkpointGate(checkpoint), approvalGate(current, checkpoint), ...validationGates].filter( @@ -1550,33 +1627,36 @@ export const layer = Layer.effect( title: `Loop evaluator: ${current.name}`, }) evaluatorStarted = Date.now() - const evaluatorResult = yield* prompt - .prompt({ - sessionID: evaluatorSession.id, - agent: current.spec.evaluation?.evaluatorAgent ?? current.spec.agent, - model: promptModel(current), - variant: current.spec.model?.variant, - tools: loopIterationTools(true), - parts: [{ type: "text", text: judgmentPrompt(current, checkpoint, preJudgeGates) }], - }) - .pipe( - Effect.map((message) => ({ - message, - judgment: parseJudgment(assistantText(message)), - })), - Effect.catchCause((cause) => { - const error = errorMessage(Cause.squash(cause)) - return Effect.succeed({ - judgment: { - status: "uncertain" as const, - summary: `Evaluator failed: ${error}`, - recommendedNextAction: "retry", - confidence: "low", - failureClass: classifyEvaluatorFailure(error), - }, - }) - }), - ) + const evaluatorResult = yield* inRunWorkspace( + run, + prompt + .prompt({ + sessionID: evaluatorSession.id, + agent: current.spec.evaluation?.evaluatorAgent ?? current.spec.agent, + model: promptModel(current), + variant: current.spec.model?.variant, + tools: loopIterationTools(true), + parts: [{ type: "text", text: judgmentPrompt(current, checkpoint, preJudgeGates) }], + }) + .pipe( + Effect.map((message) => ({ + message, + judgment: parseJudgment(assistantText(message)), + })), + Effect.catchCause((cause) => { + const error = errorMessage(Cause.squash(cause)) + return Effect.succeed({ + judgment: { + status: "uncertain" as const, + summary: `Evaluator failed: ${error}`, + recommendedNextAction: "retry", + confidence: "low", + failureClass: classifyEvaluatorFailure(error), + }, + }) + }), + ), + ) const evaluatorMessage = "message" in evaluatorResult ? evaluatorResult.message : undefined evaluatorUsage = evaluatorStarted ? usageFromMessage(evaluatorMessage, current, evaluatorStarted) : undefined judgment = evaluatorResult.judgment @@ -1632,31 +1712,7 @@ export const layer = Layer.effect( checkpoint.summary ?? "Loop run completed.") : (judgment?.summary ?? checkpoint.summary ?? completed.evaluatorReason ?? "Loop run completed.") - if ( - after.state === "completed" && - after.spec.strategy?.notifyOwnerOnComplete === true && - after.ownerSessionID && - after.ownerSessionID !== after.rootSessionID - ) { - yield* prompt - .prompt({ - sessionID: after.ownerSessionID, - agent: after.spec.agent, - model: promptModel(after), - variant: after.spec.model?.variant, - parts: [ - { - type: "text", - text: parentCompletionPrompt( - after, - { ...checkpoint, summary: judgment?.summary ?? checkpoint.summary }, - completed.id, - ), - }, - ], - }) - .pipe(Effect.ignore) - } + yield* notifyOwner(after, completed, summary) return { workflowID: id, runID: completed.id, diff --git a/src/mendcode/packages/opencode/src/session/loop.ts b/src/mendcode/packages/opencode/src/session/loop.ts index bfc1487a..ee5eeb5d 100644 --- a/src/mendcode/packages/opencode/src/session/loop.ts +++ b/src/mendcode/packages/opencode/src/session/loop.ts @@ -301,6 +301,7 @@ const Metrics = Schema.Struct({ turns: Schema.optional(NonNegativeInt), children: Schema.optional(NonNegativeInt), failures: Schema.optional(NonNegativeInt), + consecutiveFailures: Schema.optional(NonNegativeInt), noProgress: Schema.optional(NonNegativeInt), cost: Schema.optional(Schema.Number), inputTokens: Schema.optional(NonNegativeInt), @@ -860,7 +861,8 @@ function positiveInt(value: number | undefined) { function normalizeCostBudget(value: Spec["costBudget"]) { if (!value) return undefined - const maxCost = typeof value.maxCost === "number" && Number.isFinite(value.maxCost) && value.maxCost >= 0 ? value.maxCost : undefined + const maxCost = + typeof value.maxCost === "number" && Number.isFinite(value.maxCost) && value.maxCost > 0 ? value.maxCost : undefined const maxTokens = positiveInt(value.maxTokens) if (maxCost === undefined && maxTokens === undefined) return undefined return { maxCost, maxTokens } @@ -1081,20 +1083,24 @@ function retryableFailure(failureClass: FailureClass) { } function failureTransition(input: { metrics: Metrics; failureClass: FailureClass; now: number }) { - if (input.failureClass === "budget") return { state: "blocked" as const, phase: "budget_exhausted", nextWakeup: undefined, retry: undefined } - if (input.failureClass === "user_input") return { state: "needs_input" as const, phase: "needs_input", nextWakeup: undefined, retry: undefined } - if (input.failureClass === "policy") return { state: "blocked" as const, phase: "policy_blocked", nextWakeup: undefined, retry: undefined } - if (!retryableFailure(input.failureClass) || (input.metrics.failures ?? 0) >= maxFailureRetries) { + if (input.failureClass === "budget") + return { state: "blocked" as const, phase: "budget_exhausted", nextWakeup: undefined, retry: undefined } + if (input.failureClass === "user_input") + return { state: "needs_input" as const, phase: "needs_input", nextWakeup: undefined, retry: undefined } + if (input.failureClass === "policy") + return { state: "blocked" as const, phase: "policy_blocked", nextWakeup: undefined, retry: undefined } + const consecutiveFailures = input.metrics.consecutiveFailures ?? input.metrics.failures ?? 0 + if (!retryableFailure(input.failureClass) || consecutiveFailures >= maxFailureRetries) { return { state: "failed" as const, phase: "failed", nextWakeup: undefined, retry: undefined } } - const backoffMs = failureBackoffMs(input.metrics.failures ?? 1) + const backoffMs = failureBackoffMs(consecutiveFailures || 1) const nextWakeup = input.now + backoffMs return { state: "sleeping" as const, phase: "retry_scheduled", nextWakeup, retry: { - attempt: input.metrics.failures ?? 1, + attempt: consecutiveFailures || 1, backoffMs, nextWakeup, }, @@ -2331,7 +2337,11 @@ function reconcileStaleWorkingRun(row: WorkflowRow, now = Date.now()): WorkflowR data: { spec: row.data.spec, policy: row.data.policy, - metrics: { ...row.data.metrics, failures: (row.data.metrics.failures ?? 0) + 1 }, + metrics: { + ...row.data.metrics, + failures: (row.data.metrics.failures ?? 0) + 1, + consecutiveFailures: (row.data.metrics.consecutiveFailures ?? 0) + 1, + }, memory: row.data.memory, scheduler: { ...row.data.scheduler, @@ -2968,7 +2978,7 @@ export const layer = Layer.effect( requireApprovalFor: spec.approvalPolicy?.requireApprovalFor ?? policy.requireApprovalFor, approvedActions: spec.approvalPolicy?.approvedActions ?? policy.approvedActions, }, - metrics: { turns: 0, children: 0, failures: 0, noProgress: 0 }, + metrics: { turns: 0, children: 0, failures: 0, consecutiveFailures: 0, noProgress: 0 }, memory: memoryEnabled(spec) ? { entries: [] } : undefined, }, } @@ -2990,6 +3000,21 @@ export const layer = Layer.effect( if (!workflow.rootSessionID) return const ctx = yield* InstanceState.context const now = Date.now() + const existing = Database.use((db) => + db + .select() + .from(LoopThreadTable) + .where( + and(eq(LoopThreadTable.workflow_id, workflow.id), eq(LoopThreadTable.session_id, workflow.rootSessionID!)), + ) + .get(), + ) + const persistedLease = + workspaceLease ?? + (runID + ? Database.use((db) => db.select().from(LoopRunTable).where(eq(LoopRunTable.id, runID)).get())?.data + .workspaceLease + : undefined) const row: ThreadRow = { workflow_id: workflow.id, run_id: runID ?? null, @@ -3000,7 +3025,11 @@ export const layer = Layer.effect( parent_session_id: workflow.ownerSessionID ?? null, time_created: now, time_updated: now, - data: { budget: workflow.metrics, worktree: workspaceLease?.path ?? ctx.worktree, branch: workspaceLease?.branch }, + data: { + budget: workflow.metrics, + worktree: persistedLease?.path ?? existing?.data?.worktree ?? ctx.worktree, + branch: persistedLease?.branch ?? existing?.data?.branch, + }, } Database.use((db) => db @@ -3842,8 +3871,12 @@ export const layer = Layer.effect( } if (!current.rootSessionID) current = yield* activate({ id: current.id, reason: input.reason ?? "Activated for loop run.", now: input.now }) const now = input.now ?? Date.now() - if (!canStartScheduledRun(current, now)) { - return yield* Effect.fail(new NotFoundError({ message: `Loop \"${current.name}\" is sleeping until ${new Date(current.nextWakeup!).toISOString()}.` })) + if (input.trigger !== "run-once" && !canStartScheduledRun(current, now)) { + return yield* Effect.fail( + new NotFoundError({ + message: `Loop \"${current.name}\" is sleeping until ${new Date(current.nextWakeup!).toISOString()}.`, + }), + ) } const lease = runLease({ holder: input.leaseHolder, policy: current.policy, now }) const runRow: RunRow = { @@ -3990,7 +4023,10 @@ export const layer = Layer.effect( const sanitizedReason = sanitizeArtifactString(input.reason, maxArtifactSummaryChars) const pendingExternalSignal = current.spec.trigger?.mode === "external-signal" && typeof current.nextWakeup === "number" const nextWakeup = nextWakeupAfterRun(current, input.nextWakeup, now, pendingExternalSignal) - const metrics = addUsageToMetrics({ ...current.metrics, turns: (current.metrics.turns ?? 0) + 1 }, usage) + const metrics = addUsageToMetrics( + { ...current.metrics, turns: (current.metrics.turns ?? 0) + 1, consecutiveFailures: 0 }, + usage, + ) const checkpointStatus = input.goalStatus ?? sanitizedCheckpoint?.status const completionProposed = checkpointStatus === "complete" || (checkpointStatus === "blocked" && judgmentPassed(sanitizedJudgment)) const sanitizedGateResults = [ @@ -4275,7 +4311,11 @@ export const layer = Layer.effect( return fromRunRow(row) } const now = input.now ?? Date.now() - const metrics = { ...current.metrics, failures: (current.metrics.failures ?? 0) + 1 } + const metrics = { + ...current.metrics, + failures: (current.metrics.failures ?? 0) + 1, + consecutiveFailures: (current.metrics.consecutiveFailures ?? 0) + 1, + } const failureClass = input.failureClass ?? classifyFailure(input.error) const transition = failureTransition({ metrics, failureClass, now }) const pendingSignal = current.spec.trigger?.mode === "external-signal" && typeof current.nextWakeup === "number" diff --git a/src/mendcode/packages/opencode/src/session/session.sql.ts b/src/mendcode/packages/opencode/src/session/session.sql.ts index c8e88ce1..9a6b13f3 100644 --- a/src/mendcode/packages/opencode/src/session/session.sql.ts +++ b/src/mendcode/packages/opencode/src/session/session.sql.ts @@ -349,6 +349,7 @@ type LoopMetricsData = { turns?: number children?: number failures?: number + consecutiveFailures?: number noProgress?: number cost?: number inputTokens?: number diff --git a/src/mendcode/packages/opencode/src/session/workflow-runner.ts b/src/mendcode/packages/opencode/src/session/workflow-runner.ts index 8431c138..83387264 100644 --- a/src/mendcode/packages/opencode/src/session/workflow-runner.ts +++ b/src/mendcode/packages/opencode/src/session/workflow-runner.ts @@ -56,6 +56,10 @@ export function shouldRecoverWorkflowRun(state: WorkflowRunState) { return state === "queued" || state === "working" } +export function shouldAuditWorkflowCompletion(state: WorkflowRunState) { + return state === "queued" || state === "working" +} + const errorText = (error: unknown) => { if (error instanceof Error) return error.message if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") @@ -190,7 +194,7 @@ export const layer = Layer.effect( const background = yield* WorkflowBackgroundTask.Service const executor = yield* WorkflowTaskExecutor.Service const sessions = yield* Session.Service - const permissions = Option.getOrUndefined(yield* Effect.serviceOption(Permission.Service)) + const permissions = yield* Permission.Service const instances = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service)) const worktrees = Option.getOrUndefined(yield* Effect.serviceOption(Worktree.Service)) const bus = yield* Bus.Service @@ -553,30 +557,51 @@ export const layer = Layer.effect( readonly sessionPermissionMode: WorkflowSessionPermissionMode }) { const pending = input.snapshot.run.completion - if (input.snapshot.run.state !== "working") return false + if (!shouldAuditWorkflowCompletion(input.snapshot.run.state)) return false if (!pending || (pending.status !== "candidate" && pending.status !== "auditing")) return false const fingerprintBeforeResult = yield* Effect.promise(() => fingerprintWorkspace(input.directory)) const holder = `workflow-completion-auditor:${process.pid}:${crypto.randomUUID()}` + const auditLeaseMs = 10 * 60_000 const progress = yield* workflow.claimCompletionAudit({ runID: input.snapshot.run.id, holder, - leaseMs: 10 * 60_000, + leaseMs: auditLeaseMs, ...(fingerprintBeforeResult.status === "ok" ? { candidateFingerprint: fingerprintBeforeResult.value } : {}), }) if (!progress) return false + const withAuditLeaseHeartbeat = (effect: Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + yield* Effect.gen(function* () { + while (true) { + yield* Effect.sleep(Duration.millis(Math.floor(auditLeaseMs / 3))) + const renewal = yield* workflow.claimCompletionAudit({ + runID: input.snapshot.run.id, + holder, + leaseMs: auditLeaseMs, + }).pipe(Effect.option) + if (Option.isSome(renewal) && !renewal.value) return + } + }).pipe(Effect.forkScoped) + return yield* effect + }), + ) + const checks = input.snapshot.revision.plan.completion?.validationChecks ?? [] const validationAllowed = input.snapshot.revision.plan.permissions?.mode !== "report-only" && input.snapshot.revision.plan.workspace?.mode !== "read-only" - const validationResults = yield* Effect.forEach( - checks, - (check) => runCompletionValidationCommand( - check.command, - input.directory, - Math.max(1_000, Math.min(check.timeoutMs ?? 120_000, 10 * 60_000)), - validationAllowed, - ).pipe(Effect.map((result) => ({ check, result }))), - { concurrency: 1 }, + const validationResults = yield* withAuditLeaseHeartbeat( + Effect.forEach( + checks, + (check) => runCompletionValidationCommand( + check.command, + input.directory, + Math.max(1_000, Math.min(check.timeoutMs ?? 120_000, 10 * 60_000)), + validationAllowed, + ).pipe(Effect.map((result) => ({ check, result }))), + { concurrency: 1 }, + ), ) const gates: CompletionGate[] = validationResults.map(({ check, result }) => ({ id: `validation:${check.id}`, @@ -632,18 +657,20 @@ export const layer = Layer.effect( permission: Permission.withSessionMode(policy.permission, input.sessionPermissionMode), }) const executionResult = fingerprintBeforeResult.status === "ok" - ? yield* executor.execute({ - task: auditTask, - sessionID: auditSession.id, - workflowModel: input.snapshot.revision.plan.model, - workflowPermissions: input.snapshot.revision.plan.permissions, - workflowWorkspace: input.snapshot.revision.plan.workspace, - }).pipe( - Effect.catchCause((cause) => Effect.succeed({ - state: "failed", - failureClass: isTransientWorkflowError(errorText(Cause.squash(cause))) ? "transient" : "environment", - error: errorText(Cause.squash(cause)), - })), + ? yield* withAuditLeaseHeartbeat( + executor.execute({ + task: auditTask, + sessionID: auditSession.id, + workflowModel: input.snapshot.revision.plan.model, + workflowPermissions: input.snapshot.revision.plan.permissions, + workflowWorkspace: input.snapshot.revision.plan.workspace, + }).pipe( + Effect.catchCause((cause) => Effect.succeed({ + state: "failed", + failureClass: isTransientWorkflowError(errorText(Cause.squash(cause))) ? "transient" : "environment", + error: errorText(Cause.squash(cause)), + })), + ), ) : { state: "blocked" as const, @@ -822,7 +849,13 @@ export const layer = Layer.effect( const snapshot = yield* workflow.setPermissionMode(input) if (!snapshot.run.rootSessionID) return snapshot const children = yield* background.listChildren(snapshot.run.rootSessionID) - const sessionIDs = children.map((child) => child.taskID) + const sessionIDs = Array.from( + new Set([ + snapshot.run.rootSessionID, + ...snapshot.tasks.flatMap((task) => (task.sessionID ? [task.sessionID] : [])), + ...children.map((child) => child.taskID), + ]), + ) const sessionPermissionMode = snapshot.run.sessionPermissionMode ?? (yield* Effect.promise(() => readPermissionsConfig())).mode @@ -840,7 +873,7 @@ export const layer = Layer.effect( ), { concurrency: 8, discard: true }, ) - if (!permissions || sessionIDs.length === 0) return snapshot + if (sessionIDs.length === 0) return snapshot if (input.mode !== undefined && snapshot.run.permissionMode !== "custom") { yield* permissions.replyForSessions({ @@ -924,6 +957,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( + Layer.provide(Permission.defaultLayer), Layer.provide(WorkflowTaskExecutor.defaultLayer), Layer.provide(WorkflowBackgroundTask.defaultLayer), Layer.provide(WorkflowScheduler.defaultLayer), diff --git a/src/mendcode/packages/opencode/src/session/workflow-scheduler.ts b/src/mendcode/packages/opencode/src/session/workflow-scheduler.ts index 734982e0..2ad332c8 100644 --- a/src/mendcode/packages/opencode/src/session/workflow-scheduler.ts +++ b/src/mendcode/packages/opencode/src/session/workflow-scheduler.ts @@ -349,7 +349,9 @@ const reconcile = (db: DB, runID: WorkflowRunID, now: number) => { ? "blocked" : hasWorking ? "working" - : finalTaskID?.state === "completed" && allPhasesSatisfied && completion.completion?.status === "candidate" + : finalTaskID?.state === "completed" && + allPhasesSatisfied && + (completion.completion?.status === "candidate" || completion.completion?.status === "auditing") ? "working" : "queued" const state: WorkflowRunState = run.state === "stopped" diff --git a/src/mendcode/packages/opencode/src/session/workflow-service.ts b/src/mendcode/packages/opencode/src/session/workflow-service.ts index 6a51b3f8..c6ba88db 100644 --- a/src/mendcode/packages/opencode/src/session/workflow-service.ts +++ b/src/mendcode/packages/opencode/src/session/workflow-service.ts @@ -921,6 +921,19 @@ export const layer = Layer.effect( readonly workspaceLease: WorkflowWorkspaceLease }) { const current = yield* show(input.runID) + const existing = current.run.workspaceLease + if ( + existing?.id === input.workspaceLease.id && + existing.mode === input.workspaceLease.mode && + existing.path === input.workspaceLease.path && + existing.branch === input.workspaceLease.branch && + existing.state === input.workspaceLease.state && + existing.managed === input.workspaceLease.managed && + existing.createdAt === input.workspaceLease.createdAt && + existing.error === input.workspaceLease.error + ) { + return current + } const now = Date.now() Database.transaction((db) => { const row = db.select().from(WorkflowRunTable).where(eq(WorkflowRunTable.id, input.runID)).get() @@ -1224,8 +1237,25 @@ export const layer = Layer.effect( const run = db.select().from(WorkflowRunTable).where(eq(WorkflowRunTable.id, input.runID)).get() if (!run) throw new WorkflowNotFoundError(input.runID) const current = run.data.completion - if (run.state !== "working") return + if (run.state !== "queued" && run.state !== "working") return if (!current || (current.status !== "candidate" && current.status !== "auditing")) return + if (current.status === "auditing" && current.auditLease?.holder === input.holder) { + const progress: CompletionProgress = { + ...current, + auditLease: { holder: input.holder, expiresAt: now + Math.max(5_000, input.leaseMs) }, + updatedAt: now, + } + db.update(WorkflowRunTable) + .set({ + state: "working", + time_ended: null, + time_updated: now, + data: { ...run.data, completion: progress, blocker: undefined, nextAction: "Run the fresh completion audit." }, + }) + .where(eq(WorkflowRunTable.id, input.runID)) + .run() + return progress + } if (current.status === "auditing" && (current.auditLease?.expiresAt ?? 0) > now) return const progress: CompletionProgress = { ...current, diff --git a/src/mendcode/packages/opencode/src/tool/loop.ts b/src/mendcode/packages/opencode/src/tool/loop.ts index fde6c82f..99711f01 100644 --- a/src/mendcode/packages/opencode/src/tool/loop.ts +++ b/src/mendcode/packages/opencode/src/tool/loop.ts @@ -91,7 +91,7 @@ export const Parameters = Schema.Struct({ "Loop budget semantics: fixed runs exactly to a positive maxTurns cap, max-goal uses an optional maxTurns cap and completes as soon as the goal is verified, unbounded-monitor omits maxTurns and runs until stopped/blocker.", }), maxCost: Schema.optional(Schema.Number).annotate({ - description: "Optional lifetime model cost budget for the loop when usage metadata is available; omit it when Setup has no budget configured.", + description: "Optional positive lifetime model cost budget for the loop when usage metadata is available; omit it when Setup has no budget configured. Zero is treated as omitted.", }), maxTokens: Schema.optional(Schema.Number).annotate({ description: "Optional lifetime token budget for the loop when usage metadata is available; omit it when Setup has no budget configured or when no per-loop cap was explicitly requested.", @@ -465,8 +465,8 @@ function positiveMaxTurns(value: number | undefined) { return Math.floor(value) } -function nonNegativeMaxCost(value: number | undefined) { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined +function positiveMaxCost(value: number | undefined) { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined return value } @@ -508,7 +508,7 @@ function createInput(params: Schema.Schema.Type, sessionID: T (params.permissionMode === undefined && params.reportOnly !== false && !allowEditsFromObjective) const budgetMode = inferBudgetMode(params) const maxTurns = maxTurnsFor(params, budgetMode) - const maxCost = nonNegativeMaxCost(params.maxCost) + const maxCost = positiveMaxCost(params.maxCost) const maxTokens = positiveMaxTurns(params.maxTokens) const evaluationMode = params.evaluationMode ?? (budgetMode === "max-goal" ? "independent" : "legacy") const triggerMode = params.triggerMode ?? (params.dailyAt !== undefined || params.timezone !== undefined ? "daily" : params.intervalMs !== undefined ? "interval" : "manual") diff --git a/src/mendcode/packages/opencode/test/cli/tui/permission-prompt.test.ts b/src/mendcode/packages/opencode/test/cli/tui/permission-prompt.test.ts index 587502b1..97380abf 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/permission-prompt.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/permission-prompt.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test" -import { permissionPromptHoverSelection } from "@/cli/cmd/tui/routes/session/permission" +import { + permissionPromptHoverSelection, + type PermissionPromptPointerState, +} from "@/cli/cmd/tui/routes/session/permission" describe("permission prompt pointer selection", () => { test("does not let a synthetic hover undo keyboard navigation", () => { @@ -12,9 +15,20 @@ describe("permission prompt pointer selection", () => { expect(permissionPromptHoverSelection({ type: "move", x: 12, y: 5 }, "always", "once", pointer)).toBe("always") }) - test("follows the hovered option after a physical mouse move", () => { + test("does not treat the first observed cursor coordinates as mouse intent", () => { + const pointer: PermissionPromptPointerState = { keyboardNavigation: true } + expect(permissionPromptHoverSelection({ type: "move", x: 12, y: 5 }, "always", "once", pointer)).toBe("always") + expect(pointer).toEqual({ keyboardNavigation: true, x: 12, y: 5 }) + }) + + test("does not infer mouse intent from changed relative coordinates", () => { const pointer = { keyboardNavigation: true, x: 12, y: 5 } - expect(permissionPromptHoverSelection({ type: "move", x: 12, y: 7 }, "always", "once", pointer)).toBe("once") - expect(pointer.keyboardNavigation).toBe(false) + expect(permissionPromptHoverSelection({ type: "move", x: 12, y: 7 }, "always", "once", pointer)).toBe("always") + expect(pointer).toEqual({ keyboardNavigation: true, x: 12, y: 7 }) + }) + + test("follows hover while the pointer owns selection", () => { + const pointer = { keyboardNavigation: false, x: 12, y: 5 } + expect(permissionPromptHoverSelection({ type: "move", x: 12, y: 7 }, "once", "always", pointer)).toBe("always") }) }) 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 6aa2f333..5ae1bda3 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 @@ -970,8 +970,9 @@ describe("resolveWorkingStartedAt", () => { }), ).toBe("arm") expect(sessionInterruptConfirmationAction({ armedTargetMessageID: "message-1" })).toBe("ignore") - expect(sessionInterruptHint({ enabled: true, working: true, armed: false })).toBe("[esc to interrupt]") + expect(sessionInterruptHint({ enabled: true, working: true, armed: false })).toBeUndefined() 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() }) diff --git a/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts b/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts index e4d8ac12..40cf3c42 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts @@ -11,7 +11,11 @@ describe("session route initialization", () => { expect(submit.indexOf("await budgetEnforcementStatus")).toBeLessThan(submit.indexOf("clearPromptForSubmit()")) expect(submit.indexOf("await findSkill")).toBeLessThan(submit.indexOf("clearPromptForSubmit()")) - expect(submit.indexOf("clearPromptForSubmit()")).toBeLessThan(submit.indexOf("insertOptimisticUserTurn({")) + expect(submit.indexOf("await findSkill")).toBeLessThan(submit.indexOf("setSubmitPreflightActive(true)")) + expect(submit.indexOf("setSubmitPreflightActive(true)")).toBeLessThan(submit.indexOf("props.onSubmit?.({")) + expect(submit.indexOf("props.onSubmit?.({")).toBeLessThan(submit.indexOf("insertOptimisticUserTurn({")) + expect(submit.indexOf("insertOptimisticUserTurn({")).toBeLessThan(submit.indexOf("clearPromptForSubmit()")) + expect(submit.indexOf("props.onSubmit?.({")).toBeLessThan(submit.indexOf("clearPromptForSubmit()")) expect(submit.indexOf("props.onSubmit?.({")).toBeLessThan(submit.indexOf("renderer.requestRender()")) }) @@ -232,6 +236,16 @@ describe("session route initialization", () => { expect(source).toContain("const activateSubmitScrollIntent =") expect(source).toContain("const reconcileSubmitScrollIntent =") expect(source).toContain("const delta = child.y - scroll.y - intent.offset") + expect(source).toContain("if (!child || child.height <= 0 || child.y < scroll.y) return false") + expect(source).toContain(";[16, 50, 120, 240, 480].forEach((delay) =>") + expect(source).not.toContain("queueMicrotask(() => reconcileSubmitScrollIntent(token))") + const reconcileStart = source.indexOf("const reconcileSubmitScrollIntent =") + const reconcileEnd = source.indexOf("const scheduleSubmitScrollIntent =", reconcileStart) + const reconcile = source.slice(reconcileStart, reconcileEnd) + expect(reconcile.indexOf("scroll.scrollBy(delta)")).toBeLessThan(reconcile.indexOf("setFollowSessionOutput(false)")) + const activateStart = source.indexOf("const activateSubmitScrollIntent =") + const activateEnd = source.indexOf("const syncScrollFollowMode =", activateStart) + expect(source.slice(activateStart, activateEnd)).not.toContain("setFollowSessionOutput(false)") expect(source).toContain("anchorIndex: virtualScrollAnchorIndex()") expect(source).toContain("if (!info.queuedBehindActiveTurn)") expect(source).toContain("submittedTurnIDs.has(pinnedID)") diff --git a/src/mendcode/packages/opencode/test/cli/tui/session-layout.test.ts b/src/mendcode/packages/opencode/test/cli/tui/session-layout.test.ts index 42b3585c..006bf48f 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/session-layout.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/session-layout.test.ts @@ -286,6 +286,14 @@ describe("session layout", () => { expect(sessionLoopReceipt({ workflowState: "draft", workflowPhase: "draft" })).toEqual({ label: "draft", tone: "info" }) expect(sessionLoopReceipt({ workflowState: "active", workflowPhase: "ready" })).toEqual({ label: "ready", tone: "info" }) expect(sessionLoopReceipt({ workflowState: "working", workflowPhase: "monitor" })).toEqual({ label: "running", tone: "active" }) + expect( + sessionLoopReceipt({ + action: "activate", + toolStatus: "completed", + workflowState: "sleeping", + workflowPhase: "retry_scheduled", + }), + ).toEqual({ label: "retrying", tone: "warning" }) expect(sessionLoopReceipt({ workflowState: "blocked", workflowPhase: "budget_exhausted" })).toEqual({ label: "budget reached", tone: "warning" }) expect(sessionLoopReceipt({ workflowState: "needs_input" })).toEqual({ label: "needs input", tone: "warning" }) expect(sessionLoopReceipt({ workflowState: "failed" })).toEqual({ label: "failed", tone: "danger" }) diff --git a/src/mendcode/packages/opencode/test/cli/tui/workflow-view.test.ts b/src/mendcode/packages/opencode/test/cli/tui/workflow-view.test.ts index 4a0abdc0..56ce10cc 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/workflow-view.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/workflow-view.test.ts @@ -13,7 +13,9 @@ import { type WorkflowReceiptSnapshot, } from "../../../src/cli/cmd/tui/util/workflow-receipt" import { + workflowCompletionAuditActivity, workflowCurrentActivity, + workflowMonitorAuditSessionID, workflowMonitorFooter, workflowMonitorLayout, workflowMonitorResumeTarget, @@ -123,6 +125,21 @@ describe("workflow monitor helpers", () => { expect(workflowMonitorSessionID(snapshot({ tasks: [], run: { ...value.run, originSessionID: undefined, rootSessionID: "ses_root" } }))).toBe("ses_root") }) + test("opens the newest completion auditor transcript while an audit is active", () => { + expect( + workflowMonitorAuditSessionID({ + rootSessionID: "ses_root", + sessions: [ + { id: "ses_other", parentID: "ses_root", title: "Workflow task", time: { updated: 9_000 } }, + { id: "ses_audit_old", parentID: "ses_root", title: "Completion audit: Release", time: { updated: 4_000 } }, + { id: "ses_child", parentID: "ses_root", title: "Nested worker", time: { updated: 5_000 } }, + { id: "ses_audit_new", parentID: "ses_child", title: "Completion audit: Release", time: { updated: 8_000 } }, + { id: "ses_unrelated", title: "Completion audit: Other", time: { updated: 10_000 } }, + ], + }), + ).toBe("ses_audit_new") + }) + test("lists tasks in phase order instead of raw plan insertion order", () => { const value = snapshot({ phases: [ @@ -207,6 +224,36 @@ describe("workflow monitor helpers", () => { expect(workflowMonitorRows(value)).toContainEqual(["progress", "1/2 tasks"]) }) + test("reports completion audit lifecycle, lease age, and stalled recovery", () => { + const completion = { + status: "auditing", + generation: 1, + auditAttempts: 2, + auditLease: { holder: "auditor", expiresAt: 12_000 }, + createdAt: 2_000, + updatedAt: 9_000, + } + expect(workflowCompletionAuditActivity({ runState: "working", completion, now: 10_000 })).toBe( + "running · audit attempt 2 · lease 2s left", + ) + expect(workflowCompletionAuditActivity({ runState: "paused", completion, now: 10_000 })).toBe( + "paused · audit attempt 2", + ) + expect(workflowCompletionAuditActivity({ runState: "queued", completion, now: 14_000 })).toBe( + "stalled · audit attempt 2 · lease expired 2s ago", + ) + + const stalled = snapshot({ + run: { ...snapshot().run, state: "queued", completion }, + }) + expect(workflowMonitorRows(stalled, 14_000)).toContainEqual([ + "audit", + "stalled · audit attempt 2 · lease expired 2s ago", + ]) + expect(workflowMonitorRows(stalled, 14_000)).toContainEqual(["audit update", "5s ago"]) + expect(workflowReceiptNextAction(stalled, 14_000)).toContain("resume") + }) + test("prioritizes human waits over generic session status", () => { expect( workflowCurrentActivity({ @@ -251,6 +298,19 @@ describe("workflow monitor helpers", () => { statuses: [{ type: "busy", message: "Running a command" }], }), ).toBe("Workflow approval required") + expect( + workflowCurrentActivity({ + runState: "queued", + statuses: [], + completion: { + status: "auditing", + generation: 1, + auditAttempts: 1, + auditLease: { holder: "auditor", expiresAt: 9_000 }, + }, + now: 10_000, + }), + ).toContain("stalled") }) test("prioritizes blockers and operator input in the next action", () => { diff --git a/src/mendcode/packages/opencode/test/server/loop-routes.test.ts b/src/mendcode/packages/opencode/test/server/loop-routes.test.ts index 8733dec2..216e8b4e 100644 --- a/src/mendcode/packages/opencode/test/server/loop-routes.test.ts +++ b/src/mendcode/packages/opencode/test/server/loop-routes.test.ts @@ -3,6 +3,7 @@ import { Server } from "../../src/server/server" import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { defaultLayer as loopWorkflowLayer, LoopWorkflow, Service as LoopWorkflowService } from "../../src/session/loop" +import { LoopRunner } from "../../src/session/loop-runner" import { Effect } from "effect" import { HttpRouter } from "effect/unstable/http" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" @@ -42,11 +43,35 @@ describe("loop routes", () => { name: "PR babysitter", objective: "Watch PR comments and report actionable work.", trigger: { mode: "interval", intervalMs: 60_000 }, + budgetMode: "max-goal", + completionCriteria: ["actionable work is reported"], + successChecks: ["inspect current PR state"], + strategy: { targetTurns: 2, reserveTurns: 1, notifyOwnerOnComplete: true }, + model: { providerID: "local", modelID: "loop-proof", variant: "medium" }, + agent: "build", + evaluation: { mode: "deterministic", confirmation: "same-run" }, + workspace: { mode: "per-run-worktree" }, + approvalPolicy: { requireApprovalFor: ["push"], approvedActions: ["edit"] }, + memory: { enabled: true, sections: ["tried", "verified"] }, + retention: { maxArtifacts: 25, maxAgeMs: 60_000, maxBytes: 1_000_000 }, }), }) expect(draftResponse.status).toBe(200) const draft = (await draftResponse.json()) as LoopWorkflow.Info expect(draft.state).toBe("draft") + expect(draft.spec).toMatchObject({ + budgetMode: "max-goal", + completionCriteria: ["actionable work is reported"], + successChecks: ["inspect current PR state"], + strategy: { targetTurns: 2, reserveTurns: 1, notifyOwnerOnComplete: true }, + model: { providerID: "local", modelID: "loop-proof", variant: "medium" }, + agent: "build", + evaluation: { mode: "deterministic", confirmation: "same-run" }, + workspace: { mode: "per-run-worktree" }, + approvalPolicy: { requireApprovalFor: ["push"], approvedActions: ["edit"] }, + memory: { enabled: true, sections: ["tried", "verified"] }, + retention: { maxArtifacts: 25, maxAgeMs: 60_000, maxBytes: 1_000_000 }, + }) const listResponse = await app.request("/loop") expect(listResponse.status).toBe(200) @@ -69,18 +94,26 @@ describe("loop routes", () => { expect(active.rootSessionID).toBeDefined() expect(active.state).toBe("sleeping") + const pauseResponse = await app.request(`/loop/${draft.id}/pause`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ reason: "keep route test deterministic" }), + }) + expect(pauseResponse.status).toBe(200) + expect((await pauseResponse.json()) as LoopWorkflow.Info).toMatchObject({ state: "paused" }) + const runResponse = await app.request(`/loop/${draft.id}/run-once`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ reason: "test run" }), }) expect(runResponse.status).toBe(200) - expect((await runResponse.json()) as LoopWorkflow.RunInfo).toMatchObject({ trigger: "run-once", state: "completed" }) + expect((await runResponse.json()) as LoopRunner.TickResult).toMatchObject({ state: "skipped" }) const eventsResponse = await app.request(`/loop/${draft.id}/events`) expect(eventsResponse.status).toBe(200) const events = (await eventsResponse.json()) as LoopWorkflow.JournalEvent[] - expect(events.map((event) => event.type)).toEqual(["created", "activated", "wake"]) + expect(events.map((event) => event.type)).toEqual(["created", "activated", "paused"]) const stopResponse = await app.request(`/loop/${draft.id}/stop`, { method: "POST", @@ -102,7 +135,6 @@ describe("loop routes", () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const legacy = Server.Legacy().app const handler = HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true }).handler const effect = { request(input: string | URL | Request, init?: RequestInit) { @@ -111,17 +143,68 @@ describe("loop routes", () => { } const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const draftResponse = await legacy.request("/loop/draft", { + const draftResponse = await effect.request("/loop/draft", { method: "POST", headers, body: JSON.stringify({ name: "Effect dashboard loop", objective: "Verify Effect raw route can serve the loops dashboard.", trigger: { mode: "interval", intervalMs: 60_000 }, + budgetMode: "max-goal", + completionCriteria: ["raw route preserves the contract"], + strategy: { notifyOwnerOnComplete: true }, + model: { providerID: "local", modelID: "loop-proof" }, + workspace: { mode: "read-only" }, }), }) expect(draftResponse.status).toBe(200) const draft = (await draftResponse.json()) as LoopWorkflow.Info + expect(draft.state).toBe("draft") + expect(draft.spec).toMatchObject({ + budgetMode: "max-goal", + completionCriteria: ["raw route preserves the contract"], + strategy: { notifyOwnerOnComplete: true }, + model: { providerID: "local", modelID: "loop-proof" }, + workspace: { mode: "read-only" }, + }) + + const activateResponse = await effect.request(`/loop/${draft.id}/activate`, { + method: "POST", + headers, + body: JSON.stringify({ reason: "effect route activation", ensureService: false }), + }) + expect(activateResponse.status).toBe(200) + expect((await activateResponse.json()) as LoopWorkflow.Info).toMatchObject({ id: draft.id, state: "sleeping" }) + + const seededRun = await runLoop( + LoopWorkflowService.use((loop) => + loop.startRun({ id: draft.id, trigger: "manual", reason: "seed route limit" }), + ), + ) + await runLoop( + LoopWorkflowService.use((loop) => + loop.completeRun({ + id: draft.id, + runID: seededRun.id, + reason: "seed one completed iteration", + goalStatus: "continue", + checkpoint: { status: "continue", summary: "route wiring checkpoint" }, + }), + ), + ) + const pauseResponse = await effect.request(`/loop/${draft.id}/pause`, { + method: "POST", + headers, + body: JSON.stringify({ reason: "pause before deterministic route invocation" }), + }) + expect(pauseResponse.status).toBe(200) + const runResponse = await effect.request(`/loop/${draft.id}/run-once`, { + method: "POST", + headers, + body: JSON.stringify({ reason: "verify handler service wiring" }), + }) + expect(runResponse.status).toBe(200) + expect((await runResponse.json()) as LoopRunner.TickResult).toMatchObject({ state: "skipped" }) const listResponse = await effect.request("/loop", { headers }) expect(listResponse.status).toBe(200) diff --git a/src/mendcode/packages/opencode/test/session/loop.test.ts b/src/mendcode/packages/opencode/test/session/loop.test.ts index b9ab7349..4b7bdcad 100644 --- a/src/mendcode/packages/opencode/test/session/loop.test.ts +++ b/src/mendcode/packages/opencode/test/session/loop.test.ts @@ -33,6 +33,9 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { WorkspaceID } from "@/control-plane/schema" import path from "path" import { markPermissionPending, markPermissionResolved } from "@/session/pending-input" +import { InstanceState } from "@/effect/instance-state" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" function promptMessage( text?: string, @@ -104,9 +107,11 @@ function runWithWorktree(fx: Effect.Effect( fx: Effect.Effect, promptText?: string | MessageV2.WithParts | ((call: number) => string | MessageV2.WithParts), + options?: { isolatedWorkspaceContext?: boolean }, ) { let prompts = 0 const promptCalls: PromptInput[] = [] + const promptDirectories: string[] = [] const promptLayer = Layer.succeed( SessionPrompt.Service, SessionPrompt.Service.of({ @@ -115,9 +120,10 @@ function runRunner( cancelQueued: () => Effect.succeed(false), interrupt: () => Effect.void, prompt: (input: PromptInput) => - Effect.sync(() => { + Effect.gen(function* () { prompts++ promptCalls.push(input) + promptDirectories.push((yield* InstanceState.context).directory) const response = typeof promptText === "function" ? promptText(prompts) : promptText return typeof response === "string" ? promptMessage(response) @@ -143,11 +149,50 @@ function runRunner( resolvePromptParts: () => Effect.succeed([]), }), ) + const instanceStoreLayer = Layer.succeed( + InstanceStore.Service, + InstanceStore.Service.of({ + load: (input) => + InstanceState.context.pipe( + Effect.map((current) => ({ + directory: input.directory, + worktree: input.worktree ?? input.directory, + project: input.project ?? current.project, + })), + ), + reload: (input) => + InstanceState.context.pipe( + Effect.map((current) => ({ + directory: input.directory, + worktree: input.worktree ?? input.directory, + project: input.project ?? current.project, + })), + ), + dispose: () => Effect.void, + disposeAll: () => Effect.void, + loaded: () => Effect.succeed([]), + provide: (input, effect) => + InstanceState.context.pipe( + Effect.flatMap((current) => + effect.pipe( + Effect.provideService(InstanceRef, { + directory: input.directory, + worktree: input.worktree ?? input.directory, + project: input.project ?? current.project, + }), + ), + ), + ), + }), + ) + const runnerLayer = Layer.mergeAll(loopWorkflowLayer, LoopRunner.defaultLayer, Session.defaultLayer, promptLayer) return Effect.runPromise( fx.pipe( - Effect.provide(Layer.mergeAll(loopWorkflowLayer, LoopRunner.defaultLayer, Session.defaultLayer, promptLayer)), + Effect.provide( + options?.isolatedWorkspaceContext ? runnerLayer.pipe(Layer.provideMerge(instanceStoreLayer)) : runnerLayer, + ), ), - ).then((value) => ({ value, prompts, promptCalls })) + ).then((value) => ({ value, prompts, promptCalls, promptDirectories })) } const svc = { @@ -976,6 +1021,24 @@ describe("loop workflow service", () => { }, ) + await runWithWorktree( + LoopWorkflowService.use((loop) => + loop.completeRun({ + id: draft.id, + runID: run.id, + goalStatus: "continue", + checkpoint: { status: "continue", summary: "Keep the isolated workspace authoritative." }, + }), + ), + ) + expect( + (await runWithWorktree(LoopWorkflowService.use((loop) => loop.snapshot(draft.id)))).threads[0], + ).toMatchObject({ + runID: run.id, + worktree: run.workspaceLease?.path, + branch: run.workspaceLease?.branch, + }) + if (run.workspaceLease?.path) { await runWithWorktree( Worktree.Service.use((worktree) => worktree.remove({ directory: run.workspaceLease!.path })), @@ -1764,7 +1827,7 @@ describe("loop workflow service", () => { objective: "Keep monitoring without an accidental zero token cap.", trigger: { mode: "interval", intervalMs: 3_600_000 }, budgetMode: "unbounded-monitor", - costBudget: { maxTokens: 0 }, + costBudget: { maxCost: 0, maxTokens: 0 }, }) const active = await svc.activate(draft.id) expect(active.spec.costBudget).toBeUndefined() @@ -1867,7 +1930,7 @@ describe("loop workflow service", () => { }) }) - test("rehydrates legacy zero token budget blocks as scheduled monitors", async () => { + test("rehydrates legacy zero cost and token budget blocks as scheduled monitors", async () => { await using tmp = await tmpdir({ git: true }) await WithInstance.provide({ directory: tmp.path, @@ -1913,7 +1976,7 @@ describe("loop workflow service", () => { phase: "budget_exhausted", next_wakeup: null, time_updated: now, - data: { ...workflow.data, spec: { ...workflow.data.spec, costBudget: { maxTokens: 0 } } }, + data: { ...workflow.data, spec: { ...workflow.data.spec, costBudget: { maxCost: 0, maxTokens: 0 } } }, }) .where(eq(LoopWorkflowTable.id, draft.id)) .run() @@ -2332,7 +2395,7 @@ describe("loop workflow service", () => { LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), promptMessage("partial TerraPredict output", { finish: "" }), ) - expect(incomplete.value.state).toBe("failed") + expect(incomplete.value.state).toBe("retrying") expect(incomplete.value.summary).toContain("retry scheduled") const snapshot = await svc.snapshot(draft.id) @@ -2360,7 +2423,7 @@ describe("loop workflow service", () => { LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), promptMessage("finished but omitted checkpoint", { finish: "stop" }), ) - expect(missingCheckpoint.value.state).toBe("failed") + expect(missingCheckpoint.value.state).toBe("retrying") expect(missingCheckpoint.value.summary).toContain("retry scheduled") expect((await svc.snapshot(draft.id)).workflow.failureClass).toBe("transient") }, @@ -2460,6 +2523,139 @@ describe("loop workflow service", () => { }) }) + test("explicit run-once bypasses retry backoff without making scheduled runs early", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const draft = await svc.createDraft({ + name: "Manual retry override", + objective: "Let the operator retry immediately while automatic scheduling keeps its backoff.", + trigger: { mode: "interval", intervalMs: 60_000 }, + policy: { maxTurns: 5 }, + }) + await svc.activate(draft.id) + const first = await svc.startRun(draft.id) + const failed = await svc.failRunWithError(draft.id, first.id, "temporary provider failure", "transient") + expect(failed.retry?.nextWakeup).toBeGreaterThan(Date.now()) + + await expect(svc.startRun(draft.id)).rejects.toThrow() + const manual = await run( + LoopWorkflowService.use((loop) => + loop.startRun({ id: draft.id, trigger: "run-once", reason: "operator retry now" }), + ), + ) + expect(manual).toMatchObject({ state: "working", trigger: "run-once", phase: "executing" }) + }, + }) + }) + + test("a successful checkpoint resets the consecutive retry budget", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const draft = await svc.createDraft({ + name: "Retry streak reset", + objective: "Keep working after isolated malformed responses.", + trigger: { mode: "self-paced" }, + policy: { maxTurns: 6 }, + }) + await svc.activate(draft.id) + const malformed = promptMessage("missing checkpoint", { finish: "stop" }) + const firstFailure = await runRunner( + LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), + malformed, + ) + expect(firstFailure.value.state).toBe("retrying") + + Database.use((db) => + db + .update(LoopWorkflowTable) + .set({ next_wakeup: Date.now() - 1 }) + .where(eq(LoopWorkflowTable.id, draft.id)) + .run(), + ) + await runRunner( + LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), + "LOOP_CHECKPOINT:\nstatus: continue\nsummary: Valid checkpoint.\nevidence:\n- state inspected\nnext_action: continue", + ) + expect((await svc.snapshot(draft.id)).workflow.metrics.consecutiveFailures).toBe(0) + + for (let attempt = 1; attempt <= 2; attempt++) { + Database.use((db) => + db + .update(LoopWorkflowTable) + .set({ next_wakeup: Date.now() - 1 }) + .where(eq(LoopWorkflowTable.id, draft.id)) + .run(), + ) + const retried = await runRunner( + LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), + malformed, + ) + expect(retried.value.state).toBe("retrying") + } + + const snapshot = await svc.snapshot(draft.id) + expect(snapshot.workflow.state).toBe("sleeping") + expect(snapshot.workflow.metrics.failures).toBe(3) + expect(snapshot.workflow.metrics.consecutiveFailures).toBe(2) + }, + }) + }) + + test("terminal loop failures notify the owner with authoritative persisted state", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const owner = await Effect.runPromise( + Session.Service.use((session) => session.create({ title: "Failure owner" })).pipe( + Effect.provide(Session.defaultLayer), + ), + ) + const draft = await svc.createDraft({ + name: "Notify terminal failure", + objective: "Report a persistent malformed worker response.", + ownerSessionID: owner.id, + trigger: { mode: "self-paced" }, + policy: { maxTurns: 6 }, + }) + await svc.activate(draft.id) + const malformed = promptMessage("finished without checkpoint", { finish: "stop" }) + + for (let attempt = 1; attempt <= 2; attempt++) { + const retried = await runRunner( + LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), + malformed, + ) + expect(retried.value.state).toBe("retrying") + expect(retried.prompts).toBe(1) + Database.use((db) => + db + .update(LoopWorkflowTable) + .set({ next_wakeup: Date.now() - 1 }) + .where(eq(LoopWorkflowTable.id, draft.id)) + .run(), + ) + } + + const terminal = await runRunner( + LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), + (call) => (call === 1 ? malformed : "Owner acknowledged terminal state."), + ) + expect(terminal.value.state).toBe("failed") + expect(terminal.value.summary).toContain("will not retry automatically") + expect(terminal.prompts).toBe(2) + expect(terminal.promptCalls[1]?.sessionID).toBe(owner.id) + expect(promptInputText(terminal.promptCalls[1])).toContain('') + expect(promptInputText(terminal.promptCalls[1])).toContain("State: failed") + expect(promptInputText(terminal.promptCalls[1])).toContain("do not claim it is still working") + }, + }) + }) + test("policy and user-input failures block without retry", async () => { await using tmp = await tmpdir({ git: true }) await WithInstance.provide({ @@ -4541,6 +4737,63 @@ describe("loop workflow service", () => { }) }) + test("isolated loops execute in the leased worktree and hand its location back to the owner", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const owner = await Effect.runPromise( + Session.Service.use((session) => session.create({ title: "Isolated loop owner" })).pipe( + Effect.provide(Session.defaultLayer), + ), + ) + const draft = await svc.createDraft({ + name: "Authoritative isolated workspace", + objective: "Make and report isolated changes without losing their location.", + ownerSessionID: owner.id, + budgetMode: "max-goal", + strategy: { notifyOwnerOnComplete: true }, + workspace: { mode: "per-run-worktree" }, + policy: { maxTurns: 3 }, + }) + await svc.activate(draft.id) + + const executed = await runRunner( + LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), + (call) => + call === 1 + ? "LOOP_CHECKPOINT:\nstatus: complete\nsummary: Isolated work completed.\nevidence:\n- workspace inspected\nnext_action: stop\nconfidence: high" + : "Owner acknowledged authoritative workspace.", + { isolatedWorkspaceContext: true }, + ) + const snapshot = await svc.snapshot(draft.id) + const run = snapshot.runs[0] + const workspace = run?.workspaceLease?.path + const branch = run?.workspaceLease?.branch + + expect(executed.value.state).toBe("completed") + expect(workspace).toBeTruthy() + expect(workspace).not.toBe(tmp.path) + expect(executed.promptDirectories).toEqual([workspace!, tmp.path]) + expect(promptInputText(executed.promptCalls[0])).toContain(`Authoritative workspace directory: ${workspace}`) + expect(promptInputText(executed.promptCalls[0])).toContain(`Authoritative workspace branch: ${branch}`) + expect(promptInputText(executed.promptCalls[1])).toContain(`Authoritative workspace: ${workspace}`) + expect(promptInputText(executed.promptCalls[1])).toContain("may be absent from the parent/original checkout") + const ownerNotification = executed.promptCalls[1]?.parts.find((part) => part.type === "text") + expect(ownerNotification?.metadata).toMatchObject({ + kind: "loop_owner_notification", + workspacePath: workspace, + workspaceBranch: branch, + workspaceMode: "per-run-worktree", + }) + + if (workspace) { + await runWithWorktree(Worktree.Service.use((worktree) => worktree.remove({ directory: workspace }))) + } + }, + }) + }) + test("executes explicit validation commands before judgment and persists output evidence", async () => { await using tmp = await tmpdir({ git: true }) await WithInstance.provide({ diff --git a/src/mendcode/packages/opencode/test/session/workflow-scheduler.test.ts b/src/mendcode/packages/opencode/test/session/workflow-scheduler.test.ts index 38af75e4..dc83a60f 100644 --- a/src/mendcode/packages/opencode/test/session/workflow-scheduler.test.ts +++ b/src/mendcode/packages/opencode/test/session/workflow-scheduler.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import { Effect, Exit, Fiber, Layer } from "effect" import { Bus } from "@/bus" +import { Permission } from "@/permission" import { Database, eq } from "@/storage/db" import { BackgroundTask } from "@/session/background-task" import { BackgroundTaskEventTable, BackgroundTaskRunTable } from "@/session/background-task.sql" @@ -132,6 +133,9 @@ describe("workflow scheduler readiness", () => { test("recovers only runnable workflow states after a process restart", () => { expect(WorkflowRunner.shouldRecoverWorkflowRun("queued")).toBe(true) expect(WorkflowRunner.shouldRecoverWorkflowRun("working")).toBe(true) + expect(WorkflowRunner.shouldAuditWorkflowCompletion("queued")).toBe(true) + expect(WorkflowRunner.shouldAuditWorkflowCompletion("working")).toBe(true) + expect(WorkflowRunner.shouldAuditWorkflowCompletion("paused")).toBe(false) expect(WorkflowRunner.shouldRecoverWorkflowRun("awaiting_approval")).toBe(false) expect(WorkflowRunner.shouldRecoverWorkflowRun("paused")).toBe(false) expect(WorkflowRunner.shouldRecoverWorkflowRun("needs_input")).toBe(false) @@ -179,6 +183,8 @@ const it = testEffect(Layer.mergeAll(Session.defaultLayer, schedulerLayer, bus)) const unused = () => Effect.die("unused workflow recovery dependency") const recoveryDeps = Layer.mergeAll( Session.defaultLayer, + Permission.defaultLayer, + BackgroundTask.defaultLayer, schedulerLayer, bus, Layer.succeed( @@ -242,6 +248,12 @@ describe("workflow scheduler persistence", () => { leaseMs: 60_000, candidateFingerprint: "workspace-fingerprint", }) + const renewed = yield* workflow.claimCompletionAudit({ + runID: started.run.id, + holder: "auditor-one", + leaseMs: 120_000, + candidateFingerprint: "workspace-fingerprint", + }) const duplicate = yield* workflow.claimCompletionAudit({ runID: started.run.id, holder: "auditor-two", @@ -249,6 +261,8 @@ describe("workflow scheduler persistence", () => { candidateFingerprint: "workspace-fingerprint", }) expect(claimed).toMatchObject({ status: "auditing", auditAttempts: 1 }) + expect(renewed).toMatchObject({ status: "auditing", auditAttempts: 1 }) + expect(renewed!.auditLease!.expiresAt).toBeGreaterThanOrEqual(claimed!.auditLease!.expiresAt) expect(duplicate).toBeUndefined() Database.use((db) => { @@ -256,6 +270,7 @@ describe("workflow scheduler persistence", () => { if (!persisted?.data.completion) throw new Error("Expected persisted completion audit") db.update(WorkflowRunTable) .set({ + state: "queued", data: { ...persisted.data, completion: { @@ -290,6 +305,12 @@ describe("workflow scheduler persistence", () => { .where(eq(WorkflowRunTable.id, started.run.id)) .run() }) + const recoveryTick = yield* scheduler.tick(started.run.id) + expect(recoveryTick).toMatchObject({ state: "working", claimed: [] }) + expect((yield* workflow.show(started.run.id)).run.state).toBe("working") + Database.use((db) => + db.update(WorkflowRunTable).set({ state: "queued" }).where(eq(WorkflowRunTable.id, started.run.id)).run(), + ) const reclaimed = yield* workflow.claimCompletionAudit({ runID: started.run.id, holder: "auditor-two", @@ -297,6 +318,7 @@ describe("workflow scheduler persistence", () => { candidateFingerprint: "workspace-fingerprint", }) expect(reclaimed).toMatchObject({ status: "auditing", auditAttempts: 2 }) + expect((yield* workflow.show(started.run.id)).run.state).toBe("working") const completed = yield* workflow.applyCompletionAudit({ runID: started.run.id, @@ -335,6 +357,26 @@ describe("workflow scheduler persistence", () => { }), ) + it.instance("does not append duplicate workspace events when a runner reuses the same lease", () => + Effect.gen(function* () { + const workflow = yield* WorkflowService.Service + const started = yield* workflow.start({ plan: retryGraph() }) + const workspaceLease = { + id: "lease_reused", + mode: "in-place" as const, + path: "/tmp/workflow-reused", + state: "active" as const, + managed: false, + createdAt: 1_000, + } + + const first = yield* workflow.setWorkspaceLease({ runID: started.run.id, workspaceLease }) + const second = yield* workflow.setWorkspaceLease({ runID: started.run.id, workspaceLease }) + expect(second.run.updatedAt).toBe(first.run.updatedAt) + expect(second.events.filter((event) => event.title === "Workflow workspace updated")).toHaveLength(1) + }), + ) + it.instance("reopens only failed completion owners and their descendants", () => Effect.gen(function* () { const sessions = yield* Session.Service @@ -637,6 +679,73 @@ describe("workflow scheduler persistence", () => { }), ) + recoveryIt.instance("full access resolves a pending workflow permission and resumes the same task", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const permissions = yield* Permission.Service + const backgroundTasks = yield* BackgroundTask.Service + const workflow = yield* WorkflowService.Service + const scheduler = yield* WorkflowScheduler.Service + const runner = yield* WorkflowRunner.Service + const origin = yield* sessions.create({ title: "Permission mode origin" }) + const child = yield* sessions.create({ parentID: origin.id, title: "Permission mode child" }) + const started = yield* workflow.start({ plan: retryGraph(), originSessionID: origin.id }) + Database.use((db) => + db + .update(WorkflowRunTable) + .set({ root_session_id: origin.id }) + .where(eq(WorkflowRunTable.id, started.run.id)) + .run(), + ) + const claim = (yield* scheduler.tick(started.run.id)).claimed[0] + if (!claim) throw new Error("Expected a workflow task claim") + const background = yield* backgroundTasks.start({ + taskID: child.id, + parentSessionID: origin.id, + startRunning: true, + title: "Permission mode child", + }) + yield* scheduler.markStarted({ + runID: claim.runID, + taskID: claim.taskID, + attemptID: claim.attemptID, + backgroundTaskID: child.id, + backgroundGeneration: background.generation, + }) + + markPermissionPending({ sessionID: child.id, permission: "bash", patterns: ["pnpm --version"] }, 10) + const pending = yield* permissions + .ask({ + sessionID: child.id, + permission: "bash", + patterns: ["pnpm --version"], + metadata: {}, + always: ["pnpm --version"], + ruleset: [Permission.sessionModeRule("approval")], + }) + .pipe(Effect.forkScoped) + yield* Effect.sleep("20 millis") + expect(yield* permissions.list()).toHaveLength(1) + expect(yield* backgroundTasks.get(child.id)).toMatchObject({ state: "needs_input" }) + expect((yield* workflow.show(started.run.id)).run.state).toBe("needs_input") + + yield* runner + .setPermissionMode({ runID: started.run.id, sessionMode: "full_access", actor: "test" }) + .pipe(Effect.timeout("1 second")) + yield* Fiber.join(pending).pipe(Effect.timeout("1 second")) + + expect(yield* permissions.list()).toHaveLength(0) + expect(yield* backgroundTasks.get(child.id)).toMatchObject({ + generation: background.generation, + state: "running", + }) + const resumed = yield* workflow.show(started.run.id) + expect(resumed.run.state).toBe("working") + expect(resumed.tasks.find((task) => task.id === claim.taskID)).toMatchObject({ state: "working" }) + expect((yield* sessions.get(child.id)).permission?.at(-1)).toEqual(Permission.sessionModeRule("full_access")) + }), + ) + permissionIt.instance("fails an abandoned permission atomically and idempotently", () => Effect.gen(function* () { const sessions = yield* Session.Service @@ -756,6 +865,26 @@ describe("workflow scheduler persistence", () => { const now = Date.now() Database.use((db) => { + const permissionData = { + version: 2 as const, + approved: [], + requests: [ + { + info: { + id: "per_live" as never, + sessionID: child.id, + permission: "bash", + patterns: ["git status"], + metadata: {}, + always: [], + }, + ownerRuntimeID: `permission:${process.pid}:test`, + directory: "/tmp", + timeCreated: now, + timeUpdated: now, + }, + ], + } const run = db.select().from(BackgroundTaskRunTable).where(eq(BackgroundTaskRunTable.task_id, child.id)).get() if (!run) throw new Error("Expected a background run") db.update(BackgroundTaskRunTable) @@ -780,26 +909,11 @@ describe("workflow scheduler persistence", () => { project_id: origin.projectID, time_created: now, time_updated: now, - data: { - version: 2, - approved: [], - requests: [ - { - info: { - id: "per_live" as never, - sessionID: child.id, - permission: "bash", - patterns: ["git status"], - metadata: {}, - always: [], - }, - ownerRuntimeID: `permission:${process.pid}:test`, - directory: "/tmp", - timeCreated: now, - timeUpdated: now, - }, - ], - }, + data: permissionData, + }) + .onConflictDoUpdate({ + target: PermissionTable.project_id, + set: { time_updated: now, data: permissionData }, }) .run() }) diff --git a/src/mendcode/packages/opencode/test/tool/loop.test.ts b/src/mendcode/packages/opencode/test/tool/loop.test.ts index c5ea4f3a..24bd0d1b 100644 --- a/src/mendcode/packages/opencode/test/tool/loop.test.ts +++ b/src/mendcode/packages/opencode/test/tool/loop.test.ts @@ -497,6 +497,7 @@ describe("tool.loop", () => { triggerMode: "interval", intervalMs: 3_600_000, maxTurns: 1, + maxCost: 0, maxTokens: 0, permissionMode: "report-only", reportOnly: true,