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,