From f111843833a0f4dea48e82f3123c67e6aec5e36a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:24:48 +0300 Subject: [PATCH 01/15] ci: implement and validate empty-turn guard --- .github/workflows/sync-empty-turn-fix.yml | 141 ++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 .github/workflows/sync-empty-turn-fix.yml diff --git a/.github/workflows/sync-empty-turn-fix.yml b/.github/workflows/sync-empty-turn-fix.yml new file mode 100644 index 0000000..d15e1c9 --- /dev/null +++ b/.github/workflows/sync-empty-turn-fix.yml @@ -0,0 +1,141 @@ +name: Implement empty-turn guard + +on: + push: + branches: [fix/empty-assistant-turn-loop] + paths: + - .github/workflows/sync-empty-turn-fix.yml + +permissions: + contents: write + +jobs: + implement: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: fix/empty-assistant-turn-loop + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm install + - name: Apply focused runtime patch + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs') + + function read(file) { return fs.readFileSync(file, 'utf8') } + function write(file, value) { fs.writeFileSync(file, value) } + function replaceOnce(file, before, after) { + const current = read(file) + const first = current.indexOf(before) + if (first < 0) throw new Error(`missing patch marker in ${file}: ${before.slice(0, 100)}`) + if (current.indexOf(before, first + before.length) >= 0) throw new Error(`ambiguous patch marker in ${file}`) + write(file, current.slice(0, first) + after + current.slice(first + before.length)) + } + function replaceBlock(file, startMarker, endMarker, replacement) { + const current = read(file) + const start = current.indexOf(startMarker) + if (start < 0) throw new Error(`missing start marker in ${file}`) + const end = current.indexOf(endMarker, start) + if (end < 0) throw new Error(`missing end marker in ${file}`) + write(file, current.slice(0, start) + replacement + current.slice(end)) + } + + const emptyTurnFile = 'src/source/runtime/empty-turn.js' + write(emptyTurnFile, `import { actionKind } from "../core/jobs.js"\n\nexport const DEFAULT_MAX_EMPTY_TURNS = 2\n\nexport function guardsEmptyAssistantTurn(job) {\n const kind = actionKind(job?.action, job || {})\n return kind === "prompt" || kind === "goal"\n}\n\nexport function emptyTurnLimit(job) {\n const configured = Number(job?.maxEmptyTurns || 0)\n if (Number.isFinite(configured) && configured > 0) return Math.max(1, Math.floor(configured))\n return DEFAULT_MAX_EMPTY_TURNS\n}\n\nexport function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {\n const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0)\n const currentCount = Number(job?.runCount || 0)\n if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1)\n\n if (Number.isFinite(Number(active?.previousLastRunAt))) job.lastRunAt = Number(active.previousLastRunAt)\n if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {\n job.enabled = true\n }\n\n job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1\n job.lastEmptyTurnAt = Number(timestamp) || Date.now()\n job.lastFailureReason = "empty_turn"\n\n const limit = emptyTurnLimit(job)\n const paused = job.emptyTurnCount >= limit\n if (paused) {\n job.paused = true\n delete job.runNowRequestedAt\n } else {\n job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now())\n }\n return { job, paused, count: job.emptyTurnCount, limit }\n}\n\nexport function clearEmptyAssistantTurnStreak(job) {\n if (!job) return job\n job.emptyTurnCount = 0\n if (job.lastFailureReason === "empty_turn") delete job.lastFailureReason\n return job\n}\n`) + + const hostFile = 'src/source/opencode/host.js' + replaceBlock( + hostFile, + 'export async function activeRunCompletionFromMessages', + 'export async function resolveCompactionModel', + `export function assistantMessageHasMeaningfulActivity(message) {\n const parts = Array.isArray(message?.parts) ? message.parts : []\n for (const part of parts) {\n if (!part || typeof part !== "object") continue\n if (part.type === "text" && typeof part.text === "string" && part.text.trim()) return true\n if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) return true\n }\n const info = message?.info || message || {}\n return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim())\n}\n\nexport async function activeRunCompletionFromMessages(directory, client, sessionID, active) {\n const messages = await readRecentSessionMessages(client, sessionID, directory)\n if (!messages) return "unknown"\n const ordered = orderedSessionMessages(messages)\n const tail = ordered.at(-1)\n const info = tail?.info || tail\n if (!info || info.role !== "assistant") return "incomplete"\n const completed = Number(info?.time?.completed || 0)\n const created = Number(info?.time?.created || 0)\n if (!Number.isFinite(completed) || completed <= 0) return "incomplete"\n const startedAt = Number(active?.startedAt || 0)\n if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"\n\n const relevant = ordered.filter((message) => {\n const candidate = message?.info || message || {}\n if (candidate.role !== "assistant") return false\n if (startedAt <= 0) return true\n const candidateCreated = Number(candidate?.time?.created || 0)\n const candidateCompleted = Number(candidate?.time?.completed || 0)\n return candidateCreated >= startedAt || candidateCompleted >= startedAt\n })\n return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty"\n}\n\n` + ) + + const statusFile = 'src/source/runtime/session-status.js' + replaceOnce( + statusFile, + `function nonNegativeNumber(value, fallback) {\n const number = Number(value)\n return Number.isFinite(number) && number >= 0 ? number : fallback\n}\n`, + `function nonNegativeNumber(value, fallback) {\n const number = Number(value)\n return Number.isFinite(number) && number >= 0 ? number : fallback\n}\n\nfunction settledAssistantCompletion(value) {\n return value === "completed" || value === "empty"\n}\n` + ) + replaceOnce(statusFile, ' if (completion === "completed") return true\n', ' if (settledAssistantCompletion(completion)) return true\n') + replaceOnce(statusFile, ' if (completion !== "completed") return false\n', ' if (!settledAssistantCompletion(completion)) return false\n') + replaceOnce( + statusFile, + ' if (completion === "completed" || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {\n', + ' if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {\n' + ) + replaceOnce( + statusFile, + ` await appendLoopLog(\n directory,\n completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery",\n logDetails,\n )\n`, + ` const recoveryEvent = completion === "empty"\n ? "status-message-empty-recovery"\n : completion === "completed"\n ? "status-message-complete-recovery"\n : "status-stale-recovery"\n await appendLoopLog(directory, recoveryEvent, logDetails)\n` + ) + replaceOnce( + statusFile, + ' readLiveSessionStatus,\n sessionStatusType,\n', + ' readLiveSessionStatus,\n activeRunCompletion: activeRunCompletionFromMessages,\n sessionStatusType,\n' + ) + + const executorFile = 'src/source/runtime/executor.js' + replaceOnce( + executorFile, + 'import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"\n', + 'import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"\nimport { guardsEmptyAssistantTurn, refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "./empty-turn.js"\n' + ) + replaceOnce( + executorFile, + ' canFinalizeActiveRun,\n sessionStatusType,\n', + ' canFinalizeActiveRun,\n activeRunCompletion,\n sessionStatusType,\n' + ) + replaceBlock( + executorFile, + ' async function finalizeActiveRun', + '\n\n const fireAction = actionDispatcher.fireAction', + ` async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {\n const active = activeRuns.get(sessionID)\n if (!active) return\n if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false\n const completion = await activeRunCompletion(directory, client, sessionID, active)\n const recoveredStale = staleActiveRun(sessionID)\n if (active.compactionOnly) {\n const pending = compactionRuntime.getPending(sessionID)\n clearActiveRun(sessionID)\n clearSessionStatus(sessionID)\n await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {\n sessionID,\n job: active.job?.name || active.jobId,\n startedAt: active.startedAt,\n nativeEvent: Boolean(pending?.completedAt),\n })\n await scheduleDueWork(directory, client, sessionID)\n return true\n }\n\n clearActiveRun(sessionID)\n const state = await readState(directory, sessionID)\n let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)\n if (!job) return\n job.lastFinishedAt = now()\n\n if (completion === "empty" && guardsEmptyAssistantTurn(job)) {\n const empty = refundEmptyAssistantTurn(job, active, now())\n state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)\n await writeState(directory, sessionID, state)\n await appendLoopLog(directory, "empty-assistant-turn", {\n sessionID,\n job: job.name || job.id,\n count: empty.count,\n limit: empty.limit,\n paused: empty.paused,\n refunded: true,\n })\n if (empty.paused) {\n await notifyJob(directory, job, "empty_turn")\n await toast(client, `Loop paused after ${empty.count} consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.`, "warning")\n await scheduleDueWork(directory, client, sessionID)\n } else {\n await toast(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning")\n await scheduleDueWork(directory, client, sessionID, busyRetryMs)\n }\n return true\n }\n\n clearEmptyAssistantTurnStreak(job)\n if (recoveredStale) {\n await appendLoopLog(directory, "active-stale-recovery", {\n sessionID,\n job: job.name || job.id,\n startedAt: active.startedAt,\n })\n }\n\n await finalizationRuntime.finalizeJob(directory, client, sessionID, state, job, active.job)\n return true\n }` + ) + + const testFile = 'scripts/empty-turn-recovery-test.mjs' + write(testFile, `import assert from "node:assert/strict"\nimport { activeRunCompletionFromMessages, assistantMessageHasMeaningfulActivity } from "../src/source/opencode/host.js"\nimport { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"\nimport { createLoopExecutor } from "../src/source/runtime/executor.js"\nimport { refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "../src/source/runtime/empty-turn.js"\nimport { clearSessionActivity } from "../src/source/runtime/session-activity.js"\n\nconst blank = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [] }\nconst whitespace = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: " \\n" }] }\nconst text = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: "done" }] }\nconst tool = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "tool", callID: "call-1", state: { status: "completed" } }] }\nassert.equal(assistantMessageHasMeaningfulActivity(blank), false)\nassert.equal(assistantMessageHasMeaningfulActivity(whitespace), false)\nassert.equal(assistantMessageHasMeaningfulActivity(text), true)\nassert.equal(assistantMessageHasMeaningfulActivity(tool), true)\n\nlet hostMessages = [blank]\nconst hostClient = { session: { messages: async () => ({ data: hostMessages }) } }\nassert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-empty", { startedAt: 100 }), "empty")\nhostMessages = [tool, { info: { role: "assistant", time: { created: 140, completed: 150 } }, parts: [] }]\nassert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-tool-then-blank", { startedAt: 100 }), "completed", "tool activity earlier in the same logical run must prevent a false empty classification")\nhostMessages = [text]\nassert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-text", { startedAt: 100 }), "completed")\n\nconst pureJob = { id: "pure", enabled: false, paused: false, runCount: 1, maxRuns: 1, lastRunAt: 50 }\nlet pure = refundEmptyAssistantTurn(pureJob, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 200)\nassert.equal(pure.job.runCount, 0)\nassert.equal(pure.job.enabled, true)\nassert.equal(pure.job.lastRunAt, 10)\nassert.equal(pure.job.runNowRequestedAt, 200)\nassert.equal(pure.job.emptyTurnCount, 1)\nassert.equal(pure.paused, false)\npure = refundEmptyAssistantTurn(pure.job, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 300)\nassert.equal(pure.paused, true)\nassert.equal(pure.job.paused, true)\nassert.equal(pure.job.runNowRequestedAt, undefined)\nclearEmptyAssistantTurnStreak(pure.job)\nassert.equal(pure.job.emptyTurnCount, 0)\nassert.equal(pure.job.lastFailureReason, undefined)\n\nconst statusLogs = []\nconst statusActive = new Map([["status-empty", { jobId: "j", job: { id: "j" }, startedAt: 100 }]])\nconst statusRuntime = createSessionStatusRuntime({\n activeRuns: statusActive,\n now: () => 10_000,\n sessionStatusCacheMs: 0,\n activeRunCompletionFromMessages: async () => "empty",\n appendLoopLog: async (...args) => statusLogs.push(args),\n})\nconst statusClient = { session: { status: async () => ({ data: { "status-empty": { type: "busy" } } }) } }\nassert.equal(await statusRuntime.sessionStatusType(statusClient, "status-empty", "/repo"), "idle", "a host-busy tail that is already an empty completed assistant turn must settle so the empty-turn guard can run")\nassert.ok(statusLogs.some((entry) => entry[1] === "status-message-empty-recovery"))\nclearSessionActivity("status-empty")\n\nlet clock = 1_000\nlet outcome = "empty"\nconst states = new Map()\nconst schedules = []\nconst toasts = []\nconst notifications = []\nconst checkpoints = []\nconst key = (directory, sessionID) => `${directory}:${sessionID}`\nconst clone = (value) => JSON.parse(JSON.stringify(value))\nconst workspace = {\n buildPrompt: async () => "continue",\n ensureBranch: async (_directory, job) => job,\n watchChanged: async () => false,\n untilReached: async () => false,\n createCheckpoint: async (...args) => checkpoints.push(args),\n}\nconst goalPolicy = {\n runGoalChecks: async (_directory, _sessionID, job) => job,\n applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,\n}\nconst scheduler = {\n rememberSession: () => {},\n scheduleDueWork: async (...args) => schedules.push(args),\n}\nconst executor = createLoopExecutor({\n workspace,\n goalPolicy,\n scheduler,\n now: () => clock,\n readState: async (directory, sessionID) => clone(states.get(key(directory, sessionID)) || { jobs: [] }),\n writeState: async (directory, sessionID, state) => states.set(key(directory, sessionID), clone(state)),\n appendLoopLog: async () => {},\n runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),\n notifyJob: async (...args) => notifications.push(args),\n toast: async (...args) => toasts.push(args),\n fireSdk: () => Promise.resolve({}),\n compactSession: async () => true,\n activeRunCompletionFromMessages: async () => outcome,\n busyRetryMs: 5_000,\n})\nconst client = { session: { status: async () => ({ data: {} }), prompt: async () => ({ data: {} }), abort: async () => ({ data: {} }) } }\nconst directory = "/repo"\nconst sessionID = "empty-run"\nstates.set(key(directory, sessionID), { jobs: [{\n id: "job", name: "job", action: "devam et", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 1, maxRuntimeMs: 0, timeoutMs: 0,\n}] })\n\nawait executor.maybeRunDueJobs(directory, client, sessionID)\nassert.equal(states.get(key(directory, sessionID)).jobs[0].runCount, 1)\nassert.equal(states.get(key(directory, sessionID)).jobs[0].enabled, false, "max-runs is provisionally consumed at dispatch")\nclock += 100\nassert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true)\nlet persisted = states.get(key(directory, sessionID)).jobs[0]\nassert.equal(persisted.runCount, 0, "empty completed turn must refund runCount")\nassert.equal(persisted.enabled, true, "empty max-runs attempt must be re-enabled")\nassert.equal(persisted.emptyTurnCount, 1)\nassert.equal(persisted.paused, false)\nassert.equal(checkpoints.length, 0, "empty turn must not run successful finalization/checkpoint work")\nassert.ok(schedules.some((entry) => entry[2] === sessionID && entry[3] === 5_000), "first empty turn retries with bounded delay")\n\nexecutor.markSessionStatus(sessionID, "idle", clock)\nawait executor.maybeRunDueJobs(directory, client, sessionID)\nclock += 100\nassert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true)\npersisted = states.get(key(directory, sessionID)).jobs[0]\nassert.equal(persisted.runCount, 0)\nassert.equal(persisted.emptyTurnCount, 2)\nassert.equal(persisted.paused, true, "second consecutive empty completed turn must fail safe instead of looping forever")\nassert.ok(notifications.some((entry) => entry[1]?.id === "job" && entry[2] === "empty_turn"))\nassert.ok(toasts.some((entry) => /paused after 2 consecutive completed assistant turns/i.test(String(entry[1]))))\n\nconst recoverySession = "empty-then-success"\noutcome = "empty"\nstates.set(key(directory, recoverySession), { jobs: [{\n id: "recover", name: "recover", action: "continue", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 0, maxRuntimeMs: 0, timeoutMs: 0,\n}] })\nawait executor.maybeRunDueJobs(directory, client, recoverySession)\nclock += 100\nawait executor.finalizeActiveRun(directory, client, recoverySession)\nassert.equal(states.get(key(directory, recoverySession)).jobs[0].emptyTurnCount, 1)\nexecutor.markSessionStatus(recoverySession, "idle", clock)\noutcome = "completed"\nawait executor.maybeRunDueJobs(directory, client, recoverySession)\nclock += 100\nawait executor.finalizeActiveRun(directory, client, recoverySession)\nconst recovered = states.get(key(directory, recoverySession)).jobs[0]\nassert.equal(recovered.emptyTurnCount, 0, "a meaningful completed turn resets the consecutive empty streak")\nassert.equal(recovered.lastFailureReason, undefined)\nassert.equal(recovered.runCount, 1)\nassert.ok(checkpoints.some((entry) => entry[2]?.id === "recover"), "meaningful completion still follows normal finalization")\n\nexecutor.disposeSession(sessionID)\nexecutor.disposeSession(recoverySession)\nclearSessionActivity(sessionID)\nclearSessionActivity(recoverySession)\nconsole.log("empty-turn recovery tests passed")\n`) + + const packageFile = 'package.json' + let pkg = read(packageFile) + pkg = pkg.replace( + 'node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/terminal-guard.js', + 'node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js' + ) + pkg = pkg.replace( + 'node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/network-recovery-test.mjs', + 'node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs' + ) + pkg = pkg.replace( + 'node scripts/session-status-idle-recovery-test.mjs && node scripts/network-recovery-test.mjs', + 'node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs' + ) + write(packageFile, pkg) + NODE + - name: Validate source and regression suite + run: | + npm run check + npm test + - name: Regenerate committed stable bundle + run: | + npm run build:plugin + node --check src/index.js + node scripts/empty-turn-recovery-test.mjs + - name: Commit implementation and generated bundle + shell: bash + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add src/source/opencode/host.js src/source/runtime/session-status.js src/source/runtime/executor.js src/source/runtime/empty-turn.js scripts/empty-turn-recovery-test.mjs package.json src/index.js + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "fix: stop empty assistant turn loops" + git push origin HEAD:fix/empty-assistant-turn-loop From fe026bde360ff3397846ddd8d239b1cadc013d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:26:32 +0300 Subject: [PATCH 02/15] ci: repair empty-turn implementation workflow --- .../workflows/repair-empty-turn-workflow.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/repair-empty-turn-workflow.yml diff --git a/.github/workflows/repair-empty-turn-workflow.yml b/.github/workflows/repair-empty-turn-workflow.yml new file mode 100644 index 0000000..554e08b --- /dev/null +++ b/.github/workflows/repair-empty-turn-workflow.yml @@ -0,0 +1,45 @@ +name: Repair empty-turn implementation workflow + +on: + push: + branches: [fix/empty-assistant-turn-loop] + paths: + - .github/workflows/repair-empty-turn-workflow.yml + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: fix/empty-assistant-turn-loop + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Remove nested template literals from patch generator + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs') + const file = '.github/workflows/sync-empty-turn-fix.yml' + let text = fs.readFileSync(file, 'utf8') + const brokenToast = ' await toast(client, `Loop paused after ${empty.count} consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.`, "warning")' + const fixedToast = ' await toast(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning")' + const brokenKey = 'const key = (directory, sessionID) => `${directory}:${sessionID}`' + const fixedKey = 'const key = (directory, sessionID) => String(directory) + ":" + String(sessionID)' + if (!text.includes(brokenToast)) throw new Error('broken toast marker not found') + if (!text.includes(brokenKey)) throw new Error('broken key marker not found') + text = text.replace(brokenToast, fixedToast).replace(brokenKey, fixedKey) + fs.writeFileSync(file, text) + NODE + - name: Commit repaired implementation workflow + shell: bash + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add .github/workflows/sync-empty-turn-fix.yml + git commit -m "ci: fix empty-turn patch generator quoting" + git push origin HEAD:fix/empty-assistant-turn-loop From 084b044f238ce0f36a8ba0d4651fc7c8d0e0f12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:28:03 +0300 Subject: [PATCH 03/15] ci: add temporary empty-turn patch applicator --- scripts/ci-apply-empty-turn-fix.cjs | 408 ++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 scripts/ci-apply-empty-turn-fix.cjs diff --git a/scripts/ci-apply-empty-turn-fix.cjs b/scripts/ci-apply-empty-turn-fix.cjs new file mode 100644 index 0000000..bb42c04 --- /dev/null +++ b/scripts/ci-apply-empty-turn-fix.cjs @@ -0,0 +1,408 @@ +const fs = require('node:fs') + +function read(file) { + return fs.readFileSync(file, 'utf8') +} + +function write(file, value) { + fs.writeFileSync(file, value) +} + +function replaceOnce(file, before, after) { + const current = read(file) + const first = current.indexOf(before) + if (first < 0) throw new Error(`missing patch marker in ${file}: ${before.slice(0, 120)}`) + if (current.indexOf(before, first + before.length) >= 0) throw new Error(`ambiguous patch marker in ${file}`) + write(file, current.slice(0, first) + after + current.slice(first + before.length)) +} + +function replaceBlock(file, startMarker, endMarker, replacement) { + const current = read(file) + const start = current.indexOf(startMarker) + if (start < 0) throw new Error(`missing start marker in ${file}`) + const end = current.indexOf(endMarker, start) + if (end < 0) throw new Error(`missing end marker in ${file}`) + write(file, current.slice(0, start) + replacement + current.slice(end)) +} + +write('src/source/runtime/empty-turn.js', `import { actionKind } from "../core/jobs.js" + +export const DEFAULT_MAX_EMPTY_TURNS = 2 + +export function guardsEmptyAssistantTurn(job) { + const kind = actionKind(job?.action, job || {}) + return kind === "prompt" || kind === "goal" +} + +export function emptyTurnLimit(job) { + const configured = Number(job?.maxEmptyTurns || 0) + if (Number.isFinite(configured) && configured > 0) return Math.max(1, Math.floor(configured)) + return DEFAULT_MAX_EMPTY_TURNS +} + +export function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) { + const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0) + const currentCount = Number(job?.runCount || 0) + if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1) + + if (Number.isFinite(Number(active?.previousLastRunAt))) job.lastRunAt = Number(active.previousLastRunAt) + if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) { + job.enabled = true + } + + job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1 + job.lastEmptyTurnAt = Number(timestamp) || Date.now() + job.lastFailureReason = "empty_turn" + + const limit = emptyTurnLimit(job) + const paused = job.emptyTurnCount >= limit + if (paused) { + job.paused = true + delete job.runNowRequestedAt + } else { + job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now()) + } + return { job, paused, count: job.emptyTurnCount, limit } +} + +export function clearEmptyAssistantTurnStreak(job) { + if (!job) return job + job.emptyTurnCount = 0 + if (job.lastFailureReason === "empty_turn") delete job.lastFailureReason + return job +} +`) + +replaceBlock( + 'src/source/opencode/host.js', + 'export async function activeRunCompletionFromMessages', + 'export async function resolveCompactionModel', + `export function assistantMessageHasMeaningfulActivity(message) { + const parts = Array.isArray(message?.parts) ? message.parts : [] + for (const part of parts) { + if (!part || typeof part !== "object") continue + if (part.type === "text" && typeof part.text === "string" && part.text.trim()) return true + if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) return true + } + const info = message?.info || message || {} + return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim()) +} + +export async function activeRunCompletionFromMessages(directory, client, sessionID, active) { + const messages = await readRecentSessionMessages(client, sessionID, directory) + if (!messages) return "unknown" + const ordered = orderedSessionMessages(messages) + const tail = ordered.at(-1) + const info = tail?.info || tail + if (!info || info.role !== "assistant") return "incomplete" + const completed = Number(info?.time?.completed || 0) + const created = Number(info?.time?.created || 0) + if (!Number.isFinite(completed) || completed <= 0) return "incomplete" + const startedAt = Number(active?.startedAt || 0) + if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete" + + const relevant = ordered.filter((message) => { + const candidate = message?.info || message || {} + if (candidate.role !== "assistant") return false + if (startedAt <= 0) return true + const candidateCreated = Number(candidate?.time?.created || 0) + const candidateCompleted = Number(candidate?.time?.completed || 0) + return candidateCreated >= startedAt || candidateCompleted >= startedAt + }) + return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty" +} + +` +) + +const statusFile = 'src/source/runtime/session-status.js' +replaceOnce( + statusFile, + `function nonNegativeNumber(value, fallback) { + const number = Number(value) + return Number.isFinite(number) && number >= 0 ? number : fallback +} +`, + `function nonNegativeNumber(value, fallback) { + const number = Number(value) + return Number.isFinite(number) && number >= 0 ? number : fallback +} + +function settledAssistantCompletion(value) { + return value === "completed" || value === "empty" +} +` +) +replaceOnce(statusFile, ' if (completion === "completed") return true\n', ' if (settledAssistantCompletion(completion)) return true\n') +replaceOnce(statusFile, ' if (completion !== "completed") return false\n', ' if (!settledAssistantCompletion(completion)) return false\n') +replaceOnce( + statusFile, + ' if (completion === "completed" || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {\n', + ' if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {\n' +) +replaceOnce( + statusFile, + ` await appendLoopLog( + directory, + completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", + logDetails, + ) +`, + ` const recoveryEvent = completion === "empty" + ? "status-message-empty-recovery" + : completion === "completed" + ? "status-message-complete-recovery" + : "status-stale-recovery" + await appendLoopLog(directory, recoveryEvent, logDetails) +` +) +replaceOnce( + statusFile, + ' readLiveSessionStatus,\n sessionStatusType,\n', + ' readLiveSessionStatus,\n activeRunCompletion: activeRunCompletionFromMessages,\n sessionStatusType,\n' +) + +const executorFile = 'src/source/runtime/executor.js' +replaceOnce( + executorFile, + 'import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"\n', + 'import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"\nimport { guardsEmptyAssistantTurn, refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "./empty-turn.js"\n' +) +replaceOnce( + executorFile, + ' canFinalizeActiveRun,\n sessionStatusType,\n', + ' canFinalizeActiveRun,\n activeRunCompletion,\n sessionStatusType,\n' +) +replaceBlock( + executorFile, + ' async function finalizeActiveRun', + '\n\n const fireAction = actionDispatcher.fireAction', + ` async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) { + const active = activeRuns.get(sessionID) + if (!active) return + if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false + const completion = await activeRunCompletion(directory, client, sessionID, active) + const recoveredStale = staleActiveRun(sessionID) + if (active.compactionOnly) { + const pending = compactionRuntime.getPending(sessionID) + clearActiveRun(sessionID) + clearSessionStatus(sessionID) + await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", { + sessionID, + job: active.job?.name || active.jobId, + startedAt: active.startedAt, + nativeEvent: Boolean(pending?.completedAt), + }) + await scheduleDueWork(directory, client, sessionID) + return true + } + + clearActiveRun(sessionID) + const state = await readState(directory, sessionID) + let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId) + if (!job) return + job.lastFinishedAt = now() + + if (completion === "empty" && guardsEmptyAssistantTurn(job)) { + const empty = refundEmptyAssistantTurn(job, active, now()) + state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate) + await writeState(directory, sessionID, state) + await appendLoopLog(directory, "empty-assistant-turn", { + sessionID, + job: job.name || job.id, + count: empty.count, + limit: empty.limit, + paused: empty.paused, + refunded: true, + }) + if (empty.paused) { + await notifyJob(directory, job, "empty_turn") + await toast(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning") + await scheduleDueWork(directory, client, sessionID) + } else { + await toast(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning") + await scheduleDueWork(directory, client, sessionID, busyRetryMs) + } + return true + } + + if (completion === "completed") clearEmptyAssistantTurnStreak(job) + if (recoveredStale) { + await appendLoopLog(directory, "active-stale-recovery", { + sessionID, + job: job.name || job.id, + startedAt: active.startedAt, + }) + } + + await finalizationRuntime.finalizeJob(directory, client, sessionID, state, job, active.job) + return true + }` +) + +write('scripts/empty-turn-recovery-test.mjs', `import assert from "node:assert/strict" +import { activeRunCompletionFromMessages, assistantMessageHasMeaningfulActivity } from "../src/source/opencode/host.js" +import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js" +import { createLoopExecutor } from "../src/source/runtime/executor.js" +import { refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "../src/source/runtime/empty-turn.js" +import { clearSessionActivity } from "../src/source/runtime/session-activity.js" + +const blank = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [] } +const whitespace = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: " \\n" }] } +const text = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: "done" }] } +const tool = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "tool", callID: "call-1", state: { status: "completed" } }] } +assert.equal(assistantMessageHasMeaningfulActivity(blank), false) +assert.equal(assistantMessageHasMeaningfulActivity(whitespace), false) +assert.equal(assistantMessageHasMeaningfulActivity(text), true) +assert.equal(assistantMessageHasMeaningfulActivity(tool), true) + +let hostMessages = [blank] +const hostClient = { session: { messages: async () => ({ data: hostMessages }) } } +assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-empty", { startedAt: 100 }), "empty") +hostMessages = [tool, { info: { role: "assistant", time: { created: 140, completed: 150 } }, parts: [] }] +assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-tool-then-blank", { startedAt: 100 }), "completed", "tool activity earlier in the same logical run must prevent a false empty classification") +hostMessages = [text] +assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-text", { startedAt: 100 }), "completed") + +const pureJob = { id: "pure", enabled: false, paused: false, runCount: 1, maxRuns: 1, lastRunAt: 50 } +let pure = refundEmptyAssistantTurn(pureJob, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 200) +assert.equal(pure.job.runCount, 0) +assert.equal(pure.job.enabled, true) +assert.equal(pure.job.lastRunAt, 10) +assert.equal(pure.job.runNowRequestedAt, 200) +assert.equal(pure.job.emptyTurnCount, 1) +assert.equal(pure.paused, false) +pure = refundEmptyAssistantTurn(pure.job, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 300) +assert.equal(pure.paused, true) +assert.equal(pure.job.paused, true) +assert.equal(pure.job.runNowRequestedAt, undefined) +clearEmptyAssistantTurnStreak(pure.job) +assert.equal(pure.job.emptyTurnCount, 0) +assert.equal(pure.job.lastFailureReason, undefined) + +const statusLogs = [] +const statusActive = new Map([["status-empty", { jobId: "j", job: { id: "j" }, startedAt: 100 }]]) +const statusRuntime = createSessionStatusRuntime({ + activeRuns: statusActive, + now: () => 10_000, + sessionStatusCacheMs: 0, + activeRunCompletionFromMessages: async () => "empty", + appendLoopLog: async (...args) => statusLogs.push(args), +}) +const statusClient = { session: { status: async () => ({ data: { "status-empty": { type: "busy" } } }) } } +assert.equal(await statusRuntime.sessionStatusType(statusClient, "status-empty", "/repo"), "idle", "a host-busy tail that is already an empty completed assistant turn must settle so the empty-turn guard can run") +assert.ok(statusLogs.some((entry) => entry[1] === "status-message-empty-recovery")) +clearSessionActivity("status-empty") + +let clock = 1_000 +let outcome = "empty" +const states = new Map() +const schedules = [] +const toasts = [] +const notifications = [] +const checkpoints = [] +const key = (directory, sessionID) => String(directory) + ":" + String(sessionID) +const clone = (value) => JSON.parse(JSON.stringify(value)) +const workspace = { + buildPrompt: async () => "continue", + ensureBranch: async (_directory, job) => job, + watchChanged: async () => false, + untilReached: async () => false, + createCheckpoint: async (...args) => checkpoints.push(args), +} +const goalPolicy = { + runGoalChecks: async (_directory, _sessionID, job) => job, + applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job, +} +const scheduler = { + rememberSession: () => {}, + scheduleDueWork: async (...args) => schedules.push(args), +} +const executor = createLoopExecutor({ + workspace, + goalPolicy, + scheduler, + now: () => clock, + readState: async (directory, sessionID) => clone(states.get(key(directory, sessionID)) || { jobs: [] }), + writeState: async (directory, sessionID, state) => states.set(key(directory, sessionID), clone(state)), + appendLoopLog: async () => {}, + runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }), + notifyJob: async (...args) => notifications.push(args), + toast: async (...args) => toasts.push(args), + fireSdk: () => Promise.resolve({}), + compactSession: async () => true, + activeRunCompletionFromMessages: async () => outcome, + busyRetryMs: 5_000, +}) +const client = { session: { status: async () => ({ data: {} }), prompt: async () => ({ data: {} }), abort: async () => ({ data: {} }) } } +const directory = "/repo" +const sessionID = "empty-run" +states.set(key(directory, sessionID), { jobs: [{ + id: "job", name: "job", action: "devam et", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 1, maxRuntimeMs: 0, timeoutMs: 0, +}] }) + +await executor.maybeRunDueJobs(directory, client, sessionID) +assert.equal(states.get(key(directory, sessionID)).jobs[0].runCount, 1) +assert.equal(states.get(key(directory, sessionID)).jobs[0].enabled, false, "max-runs is provisionally consumed at dispatch") +clock += 100 +assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true) +let persisted = states.get(key(directory, sessionID)).jobs[0] +assert.equal(persisted.runCount, 0, "empty completed turn must refund runCount") +assert.equal(persisted.enabled, true, "empty max-runs attempt must be re-enabled") +assert.equal(persisted.emptyTurnCount, 1) +assert.equal(persisted.paused, false) +assert.equal(checkpoints.length, 0, "empty turn must not run successful finalization/checkpoint work") +assert.ok(schedules.some((entry) => entry[2] === sessionID && entry[3] === 5_000), "first empty turn retries with bounded delay") + +executor.markSessionStatus(sessionID, "idle", clock) +await executor.maybeRunDueJobs(directory, client, sessionID) +clock += 100 +assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true) +persisted = states.get(key(directory, sessionID)).jobs[0] +assert.equal(persisted.runCount, 0) +assert.equal(persisted.emptyTurnCount, 2) +assert.equal(persisted.paused, true, "second consecutive empty completed turn must fail safe instead of looping forever") +assert.ok(notifications.some((entry) => entry[1]?.id === "job" && entry[2] === "empty_turn")) +assert.ok(toasts.some((entry) => /paused after 2 consecutive completed assistant turns/i.test(String(entry[1])))) + +const recoverySession = "empty-then-success" +outcome = "empty" +states.set(key(directory, recoverySession), { jobs: [{ + id: "recover", name: "recover", action: "continue", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 0, maxRuntimeMs: 0, timeoutMs: 0, +}] }) +await executor.maybeRunDueJobs(directory, client, recoverySession) +clock += 100 +await executor.finalizeActiveRun(directory, client, recoverySession) +assert.equal(states.get(key(directory, recoverySession)).jobs[0].emptyTurnCount, 1) +executor.markSessionStatus(recoverySession, "idle", clock) +outcome = "completed" +await executor.maybeRunDueJobs(directory, client, recoverySession) +clock += 100 +await executor.finalizeActiveRun(directory, client, recoverySession) +const recovered = states.get(key(directory, recoverySession)).jobs[0] +assert.equal(recovered.emptyTurnCount, 0, "a meaningful completed turn resets the consecutive empty streak") +assert.equal(recovered.lastFailureReason, undefined) +assert.equal(recovered.runCount, 1) +assert.ok(checkpoints.some((entry) => entry[2]?.id === "recover"), "meaningful completion still follows normal finalization") + +executor.disposeSession(sessionID) +executor.disposeSession(recoverySession) +clearSessionActivity(sessionID) +clearSessionActivity(recoverySession) +console.log("empty-turn recovery tests passed") +`) + +let pkg = read('package.json') +const checkBefore = 'node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/terminal-guard.js' +const checkAfter = 'node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js' +const testCheckBefore = 'node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/network-recovery-test.mjs' +const testCheckAfter = 'node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs' +const testBefore = 'node scripts/session-status-idle-recovery-test.mjs && node scripts/network-recovery-test.mjs' +const testAfter = 'node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs' +for (const [before, after] of [[checkBefore, checkAfter], [testCheckBefore, testCheckAfter], [testBefore, testAfter]]) { + if (!pkg.includes(before)) throw new Error(`missing package script marker: ${before}`) + pkg = pkg.replace(before, after) +} +write('package.json', pkg) + +console.log('empty-turn source patch applied') From 74bac897cd59a3f7643d2c401bd97628b8993b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:28:14 +0300 Subject: [PATCH 04/15] ci: run empty-turn implementation and validation --- .github/workflows/run-empty-turn-fix.yml | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/run-empty-turn-fix.yml diff --git a/.github/workflows/run-empty-turn-fix.yml b/.github/workflows/run-empty-turn-fix.yml new file mode 100644 index 0000000..f789c31 --- /dev/null +++ b/.github/workflows/run-empty-turn-fix.yml @@ -0,0 +1,44 @@ +name: Run empty-turn implementation + +on: + push: + branches: [fix/empty-assistant-turn-loop] + paths: + - .github/workflows/run-empty-turn-fix.yml + +permissions: + contents: write + +jobs: + implement: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: fix/empty-assistant-turn-loop + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm install + - name: Apply empty-turn runtime patch + run: node scripts/ci-apply-empty-turn-fix.cjs + - name: Validate full source suite + run: | + npm run check + npm test + - name: Regenerate and validate committed bundle + run: | + npm run build:plugin + node --check src/index.js + node scripts/empty-turn-recovery-test.mjs + - name: Commit verified product changes + shell: bash + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add src/source/opencode/host.js src/source/runtime/session-status.js src/source/runtime/executor.js src/source/runtime/empty-turn.js scripts/empty-turn-recovery-test.mjs package.json src/index.js + git commit -m "fix: stop empty assistant turn loops" + git push origin HEAD:fix/empty-assistant-turn-loop From 8fe28b035d9e7e280ce569ee98c165c145e23cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:29:25 +0300 Subject: [PATCH 05/15] ci: update completion fixture for meaningful output --- scripts/ci-fix-empty-turn-contract.cjs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 scripts/ci-fix-empty-turn-contract.cjs diff --git a/scripts/ci-fix-empty-turn-contract.cjs b/scripts/ci-fix-empty-turn-contract.cjs new file mode 100644 index 0000000..32a738f --- /dev/null +++ b/scripts/ci-fix-empty-turn-contract.cjs @@ -0,0 +1,9 @@ +const fs = require('node:fs') +const file = 'scripts/host-adapter-contract-test.mjs' +let text = fs.readFileSync(file, 'utf8') +const before = 'messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },' +const after = 'messagesClient([{ info: { role: "assistant", text: "done", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },' +if (!text.includes(before)) throw new Error('host adapter completion fixture marker not found') +text = text.replace(before, after) +fs.writeFileSync(file, text) +console.log('host adapter meaningful-completion fixture updated') From ca09ca1a9919dae1d95dce697a84b8a0767c8db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:29:37 +0300 Subject: [PATCH 06/15] ci: include meaningful-completion fixture update --- .github/workflows/run-empty-turn-fix.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-empty-turn-fix.yml b/.github/workflows/run-empty-turn-fix.yml index f789c31..91fe0cb 100644 --- a/.github/workflows/run-empty-turn-fix.yml +++ b/.github/workflows/run-empty-turn-fix.yml @@ -24,7 +24,9 @@ jobs: bun-version: latest - run: npm install - name: Apply empty-turn runtime patch - run: node scripts/ci-apply-empty-turn-fix.cjs + run: | + node scripts/ci-apply-empty-turn-fix.cjs + node scripts/ci-fix-empty-turn-contract.cjs - name: Validate full source suite run: | npm run check @@ -39,6 +41,6 @@ jobs: run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src/source/opencode/host.js src/source/runtime/session-status.js src/source/runtime/executor.js src/source/runtime/empty-turn.js scripts/empty-turn-recovery-test.mjs package.json src/index.js + git add src/source/opencode/host.js src/source/runtime/session-status.js src/source/runtime/executor.js src/source/runtime/empty-turn.js scripts/empty-turn-recovery-test.mjs scripts/host-adapter-contract-test.mjs package.json src/index.js git commit -m "fix: stop empty assistant turn loops" git push origin HEAD:fix/empty-assistant-turn-loop From e017d510cb5f9b724079602af5afaf96c8487928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:30:44 +0300 Subject: [PATCH 07/15] ci: update adapter completion fixtures for meaningful output --- scripts/ci-fix-empty-turn-contract.cjs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/ci-fix-empty-turn-contract.cjs b/scripts/ci-fix-empty-turn-contract.cjs index 32a738f..1d25e2b 100644 --- a/scripts/ci-fix-empty-turn-contract.cjs +++ b/scripts/ci-fix-empty-turn-contract.cjs @@ -1,9 +1,21 @@ const fs = require('node:fs') const file = 'scripts/host-adapter-contract-test.mjs' let text = fs.readFileSync(file, 'utf8') -const before = 'messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },' -const after = 'messagesClient([{ info: { role: "assistant", text: "done", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },' -if (!text.includes(before)) throw new Error('host adapter completion fixture marker not found') -text = text.replace(before, after) + +const replacements = [ + [ + 'messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },', + 'messagesClient([{ info: { role: "assistant", text: "done", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },', + ], + [ + 'return { data: [{ info: { role: "assistant", time: { created: 10, completed: 20 } } }] }', + 'return { data: [{ info: { role: "assistant", text: "adapter completed", time: { created: 10, completed: 20 } } }] }', + ], +] + +for (const [before, after] of replacements) { + if (!text.includes(before)) throw new Error(`host adapter completion fixture marker not found: ${before}`) + text = text.replace(before, after) +} fs.writeFileSync(file, text) -console.log('host adapter meaningful-completion fixture updated') +console.log('host adapter meaningful-completion fixtures updated') From 696e26d566d71ef4fba43e5ec200968edf33136a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:30:52 +0300 Subject: [PATCH 08/15] ci: rerun empty-turn validation with adapter fixtures From 73bd868b5d60433f05006d66fca850fc9f40240a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:31:10 +0300 Subject: [PATCH 09/15] ci: rerun empty-turn validation pass 3 --- .github/workflows/run-empty-turn-fix.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run-empty-turn-fix.yml b/.github/workflows/run-empty-turn-fix.yml index 91fe0cb..219b1ff 100644 --- a/.github/workflows/run-empty-turn-fix.yml +++ b/.github/workflows/run-empty-turn-fix.yml @@ -9,6 +9,7 @@ on: permissions: contents: write +# validation-pass: 3 jobs: implement: runs-on: ubuntu-latest From 9fb62a6d06d53b706afede358948e82f088409d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:31:52 +0000 Subject: [PATCH 10/15] fix: stop empty assistant turn loops --- package.json | 4 +- scripts/empty-turn-recovery-test.mjs | 150 +++++++++++++++++++++++++ scripts/host-adapter-contract-test.mjs | 4 +- src/index.js | 111 +++++++++++++++++- src/source/opencode/host.js | 25 ++++- src/source/runtime/empty-turn.js | 46 ++++++++ src/source/runtime/executor.js | 28 +++++ src/source/runtime/session-status.js | 22 ++-- 8 files changed, 370 insertions(+), 20 deletions(-) create mode 100644 scripts/empty-turn-recovery-test.mjs create mode 100644 src/source/runtime/empty-turn.js diff --git a/package.json b/package.json index be66720..e8b6dea 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool", "build:plugin:npm": "npm run build:plugin", "prepack": "node --check src/index.js", - "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", - "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", + "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", + "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs", "install:global": "node scripts/install-with-goals.mjs", "pack:zip": "node scripts/make-zip.mjs" diff --git a/scripts/empty-turn-recovery-test.mjs b/scripts/empty-turn-recovery-test.mjs new file mode 100644 index 0000000..0e08a3a --- /dev/null +++ b/scripts/empty-turn-recovery-test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict" +import { activeRunCompletionFromMessages, assistantMessageHasMeaningfulActivity } from "../src/source/opencode/host.js" +import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js" +import { createLoopExecutor } from "../src/source/runtime/executor.js" +import { refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "../src/source/runtime/empty-turn.js" +import { clearSessionActivity } from "../src/source/runtime/session-activity.js" + +const blank = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [] } +const whitespace = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: " \n" }] } +const text = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: "done" }] } +const tool = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "tool", callID: "call-1", state: { status: "completed" } }] } +assert.equal(assistantMessageHasMeaningfulActivity(blank), false) +assert.equal(assistantMessageHasMeaningfulActivity(whitespace), false) +assert.equal(assistantMessageHasMeaningfulActivity(text), true) +assert.equal(assistantMessageHasMeaningfulActivity(tool), true) + +let hostMessages = [blank] +const hostClient = { session: { messages: async () => ({ data: hostMessages }) } } +assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-empty", { startedAt: 100 }), "empty") +hostMessages = [tool, { info: { role: "assistant", time: { created: 140, completed: 150 } }, parts: [] }] +assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-tool-then-blank", { startedAt: 100 }), "completed", "tool activity earlier in the same logical run must prevent a false empty classification") +hostMessages = [text] +assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-text", { startedAt: 100 }), "completed") + +const pureJob = { id: "pure", enabled: false, paused: false, runCount: 1, maxRuns: 1, lastRunAt: 50 } +let pure = refundEmptyAssistantTurn(pureJob, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 200) +assert.equal(pure.job.runCount, 0) +assert.equal(pure.job.enabled, true) +assert.equal(pure.job.lastRunAt, 10) +assert.equal(pure.job.runNowRequestedAt, 200) +assert.equal(pure.job.emptyTurnCount, 1) +assert.equal(pure.paused, false) +pure = refundEmptyAssistantTurn(pure.job, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 300) +assert.equal(pure.paused, true) +assert.equal(pure.job.paused, true) +assert.equal(pure.job.runNowRequestedAt, undefined) +clearEmptyAssistantTurnStreak(pure.job) +assert.equal(pure.job.emptyTurnCount, 0) +assert.equal(pure.job.lastFailureReason, undefined) + +const statusLogs = [] +const statusActive = new Map([["status-empty", { jobId: "j", job: { id: "j" }, startedAt: 100 }]]) +const statusRuntime = createSessionStatusRuntime({ + activeRuns: statusActive, + now: () => 10_000, + sessionStatusCacheMs: 0, + activeRunCompletionFromMessages: async () => "empty", + appendLoopLog: async (...args) => statusLogs.push(args), +}) +const statusClient = { session: { status: async () => ({ data: { "status-empty": { type: "busy" } } }) } } +assert.equal(await statusRuntime.sessionStatusType(statusClient, "status-empty", "/repo"), "idle", "a host-busy tail that is already an empty completed assistant turn must settle so the empty-turn guard can run") +assert.ok(statusLogs.some((entry) => entry[1] === "status-message-empty-recovery")) +clearSessionActivity("status-empty") + +let clock = 1_000 +let outcome = "empty" +const states = new Map() +const schedules = [] +const toasts = [] +const notifications = [] +const checkpoints = [] +const key = (directory, sessionID) => String(directory) + ":" + String(sessionID) +const clone = (value) => JSON.parse(JSON.stringify(value)) +const workspace = { + buildPrompt: async () => "continue", + ensureBranch: async (_directory, job) => job, + watchChanged: async () => false, + untilReached: async () => false, + createCheckpoint: async (...args) => checkpoints.push(args), +} +const goalPolicy = { + runGoalChecks: async (_directory, _sessionID, job) => job, + applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job, +} +const scheduler = { + rememberSession: () => {}, + scheduleDueWork: async (...args) => schedules.push(args), +} +const executor = createLoopExecutor({ + workspace, + goalPolicy, + scheduler, + now: () => clock, + readState: async (directory, sessionID) => clone(states.get(key(directory, sessionID)) || { jobs: [] }), + writeState: async (directory, sessionID, state) => states.set(key(directory, sessionID), clone(state)), + appendLoopLog: async () => {}, + runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }), + notifyJob: async (...args) => notifications.push(args), + toast: async (...args) => toasts.push(args), + fireSdk: () => Promise.resolve({}), + compactSession: async () => true, + activeRunCompletionFromMessages: async () => outcome, + busyRetryMs: 5_000, +}) +const client = { session: { status: async () => ({ data: {} }), prompt: async () => ({ data: {} }), abort: async () => ({ data: {} }) } } +const directory = "/repo" +const sessionID = "empty-run" +states.set(key(directory, sessionID), { jobs: [{ + id: "job", name: "job", action: "devam et", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 1, maxRuntimeMs: 0, timeoutMs: 0, +}] }) + +await executor.maybeRunDueJobs(directory, client, sessionID) +assert.equal(states.get(key(directory, sessionID)).jobs[0].runCount, 1) +assert.equal(states.get(key(directory, sessionID)).jobs[0].enabled, false, "max-runs is provisionally consumed at dispatch") +clock += 100 +assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true) +let persisted = states.get(key(directory, sessionID)).jobs[0] +assert.equal(persisted.runCount, 0, "empty completed turn must refund runCount") +assert.equal(persisted.enabled, true, "empty max-runs attempt must be re-enabled") +assert.equal(persisted.emptyTurnCount, 1) +assert.equal(persisted.paused, false) +assert.equal(checkpoints.length, 0, "empty turn must not run successful finalization/checkpoint work") +assert.ok(schedules.some((entry) => entry[2] === sessionID && entry[3] === 5_000), "first empty turn retries with bounded delay") + +executor.markSessionStatus(sessionID, "idle", clock) +await executor.maybeRunDueJobs(directory, client, sessionID) +clock += 100 +assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true) +persisted = states.get(key(directory, sessionID)).jobs[0] +assert.equal(persisted.runCount, 0) +assert.equal(persisted.emptyTurnCount, 2) +assert.equal(persisted.paused, true, "second consecutive empty completed turn must fail safe instead of looping forever") +assert.ok(notifications.some((entry) => entry[1]?.id === "job" && entry[2] === "empty_turn")) +assert.ok(toasts.some((entry) => /paused after 2 consecutive completed assistant turns/i.test(String(entry[1])))) + +const recoverySession = "empty-then-success" +outcome = "empty" +states.set(key(directory, recoverySession), { jobs: [{ + id: "recover", name: "recover", action: "continue", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 0, maxRuntimeMs: 0, timeoutMs: 0, +}] }) +await executor.maybeRunDueJobs(directory, client, recoverySession) +clock += 100 +await executor.finalizeActiveRun(directory, client, recoverySession) +assert.equal(states.get(key(directory, recoverySession)).jobs[0].emptyTurnCount, 1) +executor.markSessionStatus(recoverySession, "idle", clock) +outcome = "completed" +await executor.maybeRunDueJobs(directory, client, recoverySession) +clock += 100 +await executor.finalizeActiveRun(directory, client, recoverySession) +const recovered = states.get(key(directory, recoverySession)).jobs[0] +assert.equal(recovered.emptyTurnCount, 0, "a meaningful completed turn resets the consecutive empty streak") +assert.equal(recovered.lastFailureReason, undefined) +assert.equal(recovered.runCount, 1) +assert.ok(checkpoints.some((entry) => entry[2]?.id === "recover"), "meaningful completion still follows normal finalization") + +executor.disposeSession(sessionID) +executor.disposeSession(recoverySession) +clearSessionActivity(sessionID) +clearSessionActivity(recoverySession) +console.log("empty-turn recovery tests passed") diff --git a/scripts/host-adapter-contract-test.mjs b/scripts/host-adapter-contract-test.mjs index de23690..1263077 100644 --- a/scripts/host-adapter-contract-test.mjs +++ b/scripts/host-adapter-contract-test.mjs @@ -61,7 +61,7 @@ assert.equal(compactTuiCommandName("other"), undefined) "/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 }, ), "incomplete") assert.equal(await activeRunCompletionFromMessages( - "/tmp", messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 }, + "/tmp", messagesClient([{ info: { role: "assistant", text: "done", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 }, ), "completed") } @@ -118,7 +118,7 @@ assert.equal(compactTuiCommandName("other"), undefined) session: { messages: async (args) => { calls.push(["messages", args]) - return { data: [{ info: { role: "assistant", time: { created: 10, completed: 20 } } }] } + return { data: [{ info: { role: "assistant", text: "adapter completed", time: { created: 10, completed: 20 } } }] } }, }, } diff --git a/src/index.js b/src/index.js index 5b6bd48..63825ef 100644 --- a/src/index.js +++ b/src/index.js @@ -801,11 +801,25 @@ function orderedSessionMessages(messages) { return { message, index, created: Number.isFinite(created) ? created : 0 }; }).sort((a, b) => a.created - b.created || a.index - b.index).map((entry) => entry.message); } +function assistantMessageHasMeaningfulActivity(message) { + const parts = Array.isArray(message?.parts) ? message.parts : []; + for (const part of parts) { + if (!part || typeof part !== "object") + continue; + if (part.type === "text" && typeof part.text === "string" && part.text.trim()) + return true; + if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) + return true; + } + const info = message?.info || message || {}; + return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim()); +} async function activeRunCompletionFromMessages(directory, client, sessionID, active) { const messages = await readRecentSessionMessages(client, sessionID, directory); if (!messages) return "unknown"; - const tail = orderedSessionMessages(messages).at(-1); + const ordered = orderedSessionMessages(messages); + const tail = ordered.at(-1); const info = tail?.info || tail; if (!info || info.role !== "assistant") return "incomplete"; @@ -816,7 +830,17 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act const startedAt = Number(active?.startedAt || 0); if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"; - return "completed"; + const relevant = ordered.filter((message) => { + const candidate = message?.info || message || {}; + if (candidate.role !== "assistant") + return false; + if (startedAt <= 0) + return true; + const candidateCreated = Number(candidate?.time?.created || 0); + const candidateCompleted = Number(candidate?.time?.completed || 0); + return candidateCreated >= startedAt || candidateCompleted >= startedAt; + }); + return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty"; } async function resolveCompactionModel(directory, client, sessionID, preferredModel) { const preferred = normalizedModelRef(preferredModel); @@ -2853,6 +2877,9 @@ function nonNegativeNumber(value, fallback) { const number = Number(value); return Number.isFinite(number) && number >= 0 ? number : fallback; } +function settledAssistantCompletion(value) { + return value === "completed" || value === "empty"; +} function createSessionStatusRuntime(options = {}) { const activeRuns = options.activeRuns; if (!(activeRuns instanceof Map)) @@ -2947,7 +2974,7 @@ function createSessionStatusRuntime(options = {}) { if (!options2.requireIdle && !options2.forceStale) return true; const completion = options2.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined; - if (completion === "completed") + if (settledAssistantCompletion(completion)) return true; if (!options2.requireIdle) return completion === "unknown" && staleActiveRun(sessionID); @@ -2977,7 +3004,7 @@ function createSessionStatusRuntime(options = {}) { if (!seenAt || now2() - seenAt < sessionStatusCacheMs) return false; const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 }); - if (completion !== "completed") + if (!settledAssistantCompletion(completion)) return false; markSessionStatus(sessionID, "idle"); await appendLoopLog2(directory, "status-message-idle-recovery", { @@ -3012,7 +3039,7 @@ function createSessionStatusRuntime(options = {}) { const active = activeRuns.get(sessionID); if (active) { const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active); - if (completion === "completed" || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) { + if (settledAssistantCompletion(completion) || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) { markSessionStatus(sessionID, "idle"); const logDetails = { sessionID, @@ -3020,7 +3047,8 @@ function createSessionStatusRuntime(options = {}) { startedAt: active.startedAt, ...completion === "completed" ? {} : { staleStatus: live.type } }; - await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", logDetails); + const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery"; + await appendLoopLog2(directory, recoveryEvent, logDetails); return "idle"; } } @@ -3042,6 +3070,7 @@ function createSessionStatusRuntime(options = {}) { staleActiveRun, canFinalizeActiveRun, readLiveSessionStatus, + activeRunCompletion: activeRunCompletionFromMessages2, sessionStatusType, sessionIsIdle }; @@ -3532,6 +3561,50 @@ function refundInfrastructureRun(job, snapshot = {}, input = {}) { return job; } +// src/source/runtime/empty-turn.js +var DEFAULT_MAX_EMPTY_TURNS = 2; +function guardsEmptyAssistantTurn(job) { + const kind = actionKind(job?.action, job || {}); + return kind === "prompt" || kind === "goal"; +} +function emptyTurnLimit(job) { + const configured = Number(job?.maxEmptyTurns || 0); + if (Number.isFinite(configured) && configured > 0) + return Math.max(1, Math.floor(configured)); + return DEFAULT_MAX_EMPTY_TURNS; +} +function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) { + const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0); + const currentCount = Number(job?.runCount || 0); + if (chargedCount > 0 && currentCount >= chargedCount) + job.runCount = Math.max(0, currentCount - 1); + if (Number.isFinite(Number(active?.previousLastRunAt))) + job.lastRunAt = Number(active.previousLastRunAt); + if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) { + job.enabled = true; + } + job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1; + job.lastEmptyTurnAt = Number(timestamp) || Date.now(); + job.lastFailureReason = "empty_turn"; + const limit = emptyTurnLimit(job); + const paused = job.emptyTurnCount >= limit; + if (paused) { + job.paused = true; + delete job.runNowRequestedAt; + } else { + job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now()); + } + return { job, paused, count: job.emptyTurnCount, limit }; +} +function clearEmptyAssistantTurnStreak(job) { + if (!job) + return job; + job.emptyTurnCount = 0; + if (job.lastFailureReason === "empty_turn") + delete job.lastFailureReason; + return job; +} + // src/source/runtime/executor.js var DEFAULT_ACTIVE_GUARD_MS = 45000; var DEFAULT_BUSY_RETRY_MS2 = 5000; @@ -3585,6 +3658,7 @@ function createLoopExecutor(options = {}) { updateSessionStatusFromEvent, staleActiveRun, canFinalizeActiveRun, + activeRunCompletion, sessionStatusType, sessionIsIdle, markSessionStatus, @@ -3760,6 +3834,7 @@ function createLoopExecutor(options = {}) { return; if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false; + const completion = await activeRunCompletion(directory, client, sessionID, active); const recoveredStale = staleActiveRun(sessionID); if (active.compactionOnly) { const pending = compactionRuntime.getPending(sessionID); @@ -3780,6 +3855,30 @@ function createLoopExecutor(options = {}) { if (!job) return; job.lastFinishedAt = now2(); + if (completion === "empty" && guardsEmptyAssistantTurn(job)) { + const empty = refundEmptyAssistantTurn(job, active, now2()); + state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate); + await writeState2(directory, sessionID, state); + await appendLoopLog2(directory, "empty-assistant-turn", { + sessionID, + job: job.name || job.id, + count: empty.count, + limit: empty.limit, + paused: empty.paused, + refunded: true + }); + if (empty.paused) { + await notifyJob2(directory, job, "empty_turn"); + await toast2(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning"); + await scheduleDueWork(directory, client, sessionID); + } else { + await toast2(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning"); + await scheduleDueWork(directory, client, sessionID, busyRetryMs); + } + return true; + } + if (completion === "completed") + clearEmptyAssistantTurnStreak(job); if (recoveredStale) { await appendLoopLog2(directory, "active-stale-recovery", { sessionID, diff --git a/src/source/opencode/host.js b/src/source/opencode/host.js index 2d4e477..34330b7 100644 --- a/src/source/opencode/host.js +++ b/src/source/opencode/host.js @@ -54,10 +54,22 @@ export function orderedSessionMessages(messages) { .map((entry) => entry.message) } +export function assistantMessageHasMeaningfulActivity(message) { + const parts = Array.isArray(message?.parts) ? message.parts : [] + for (const part of parts) { + if (!part || typeof part !== "object") continue + if (part.type === "text" && typeof part.text === "string" && part.text.trim()) return true + if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) return true + } + const info = message?.info || message || {} + return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim()) +} + export async function activeRunCompletionFromMessages(directory, client, sessionID, active) { const messages = await readRecentSessionMessages(client, sessionID, directory) if (!messages) return "unknown" - const tail = orderedSessionMessages(messages).at(-1) + const ordered = orderedSessionMessages(messages) + const tail = ordered.at(-1) const info = tail?.info || tail if (!info || info.role !== "assistant") return "incomplete" const completed = Number(info?.time?.completed || 0) @@ -65,7 +77,16 @@ export async function activeRunCompletionFromMessages(directory, client, session if (!Number.isFinite(completed) || completed <= 0) return "incomplete" const startedAt = Number(active?.startedAt || 0) if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete" - return "completed" + + const relevant = ordered.filter((message) => { + const candidate = message?.info || message || {} + if (candidate.role !== "assistant") return false + if (startedAt <= 0) return true + const candidateCreated = Number(candidate?.time?.created || 0) + const candidateCompleted = Number(candidate?.time?.completed || 0) + return candidateCreated >= startedAt || candidateCompleted >= startedAt + }) + return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty" } export async function resolveCompactionModel(directory, client, sessionID, preferredModel) { diff --git a/src/source/runtime/empty-turn.js b/src/source/runtime/empty-turn.js new file mode 100644 index 0000000..efe49af --- /dev/null +++ b/src/source/runtime/empty-turn.js @@ -0,0 +1,46 @@ +import { actionKind } from "../core/jobs.js" + +export const DEFAULT_MAX_EMPTY_TURNS = 2 + +export function guardsEmptyAssistantTurn(job) { + const kind = actionKind(job?.action, job || {}) + return kind === "prompt" || kind === "goal" +} + +export function emptyTurnLimit(job) { + const configured = Number(job?.maxEmptyTurns || 0) + if (Number.isFinite(configured) && configured > 0) return Math.max(1, Math.floor(configured)) + return DEFAULT_MAX_EMPTY_TURNS +} + +export function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) { + const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0) + const currentCount = Number(job?.runCount || 0) + if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1) + + if (Number.isFinite(Number(active?.previousLastRunAt))) job.lastRunAt = Number(active.previousLastRunAt) + if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) { + job.enabled = true + } + + job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1 + job.lastEmptyTurnAt = Number(timestamp) || Date.now() + job.lastFailureReason = "empty_turn" + + const limit = emptyTurnLimit(job) + const paused = job.emptyTurnCount >= limit + if (paused) { + job.paused = true + delete job.runNowRequestedAt + } else { + job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now()) + } + return { job, paused, count: job.emptyTurnCount, limit } +} + +export function clearEmptyAssistantTurnStreak(job) { + if (!job) return job + job.emptyTurnCount = 0 + if (job.lastFailureReason === "empty_turn") delete job.lastFailureReason + return job +} diff --git a/src/source/runtime/executor.js b/src/source/runtime/executor.js index 6a5e0ee..16e6a6d 100644 --- a/src/source/runtime/executor.js +++ b/src/source/runtime/executor.js @@ -11,6 +11,7 @@ import { createActionDispatcher } from "./action-dispatch.js" import { createRunFinalizationRuntime } from "./run-finalization.js" import { createRunAdmissionRuntime } from "./run-admission.js" import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js" +import { guardsEmptyAssistantTurn, refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "./empty-turn.js" const DEFAULT_ACTIVE_GUARD_MS = 45_000 const DEFAULT_BUSY_RETRY_MS = 5_000 @@ -77,6 +78,7 @@ export function createLoopExecutor(options = {}) { updateSessionStatusFromEvent, staleActiveRun, canFinalizeActiveRun, + activeRunCompletion, sessionStatusType, sessionIsIdle, markSessionStatus, @@ -267,6 +269,7 @@ export function createLoopExecutor(options = {}) { const active = activeRuns.get(sessionID) if (!active) return if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false + const completion = await activeRunCompletion(directory, client, sessionID, active) const recoveredStale = staleActiveRun(sessionID) if (active.compactionOnly) { const pending = compactionRuntime.getPending(sessionID) @@ -287,6 +290,31 @@ export function createLoopExecutor(options = {}) { let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId) if (!job) return job.lastFinishedAt = now() + + if (completion === "empty" && guardsEmptyAssistantTurn(job)) { + const empty = refundEmptyAssistantTurn(job, active, now()) + state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate) + await writeState(directory, sessionID, state) + await appendLoopLog(directory, "empty-assistant-turn", { + sessionID, + job: job.name || job.id, + count: empty.count, + limit: empty.limit, + paused: empty.paused, + refunded: true, + }) + if (empty.paused) { + await notifyJob(directory, job, "empty_turn") + await toast(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning") + await scheduleDueWork(directory, client, sessionID) + } else { + await toast(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning") + await scheduleDueWork(directory, client, sessionID, busyRetryMs) + } + return true + } + + if (completion === "completed") clearEmptyAssistantTurnStreak(job) if (recoveredStale) { await appendLoopLog(directory, "active-stale-recovery", { sessionID, diff --git a/src/source/runtime/session-status.js b/src/source/runtime/session-status.js index c14d72e..15686a9 100644 --- a/src/source/runtime/session-status.js +++ b/src/source/runtime/session-status.js @@ -24,6 +24,10 @@ function nonNegativeNumber(value, fallback) { return Number.isFinite(number) && number >= 0 ? number : fallback } +function settledAssistantCompletion(value) { + return value === "completed" || value === "empty" +} + export function createSessionStatusRuntime(options = {}) { const activeRuns = options.activeRuns if (!(activeRuns instanceof Map)) throw new TypeError("createSessionStatusRuntime requires activeRuns Map") @@ -114,7 +118,7 @@ export function createSessionStatusRuntime(options = {}) { const completion = options.forceStale ? await activeRunCompletionFromMessages(directory, client, sessionID, active) : undefined - if (completion === "completed") return true + if (settledAssistantCompletion(completion)) return true if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID) const cached = sessionStatuses.get(sessionID) @@ -142,7 +146,7 @@ export function createSessionStatusRuntime(options = {}) { if (liveType !== "busy") return false if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 }) - if (completion !== "completed") return false + if (!settledAssistantCompletion(completion)) return false markSessionStatus(sessionID, "idle") await appendLoopLog(directory, "status-message-idle-recovery", { sessionID, @@ -177,7 +181,7 @@ export function createSessionStatusRuntime(options = {}) { const active = activeRuns.get(sessionID) if (active) { const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active) - if (completion === "completed" || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) { + if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) { markSessionStatus(sessionID, "idle") const logDetails = { sessionID, @@ -185,11 +189,12 @@ export function createSessionStatusRuntime(options = {}) { startedAt: active.startedAt, ...(completion === "completed" ? {} : { staleStatus: live.type }), } - await appendLoopLog( - directory, - completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", - logDetails, - ) + const recoveryEvent = completion === "empty" + ? "status-message-empty-recovery" + : completion === "completed" + ? "status-message-complete-recovery" + : "status-stale-recovery" + await appendLoopLog(directory, recoveryEvent, logDetails) return "idle" } } @@ -214,6 +219,7 @@ export function createSessionStatusRuntime(options = {}) { staleActiveRun, canFinalizeActiveRun, readLiveSessionStatus, + activeRunCompletion: activeRunCompletionFromMessages, sessionStatusType, sessionIsIdle, } From 306e80580aafb1d7eaa6042dee9ab7438307ea36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:33:02 +0300 Subject: [PATCH 11/15] ci: remove temporary empty-turn implementation workflow --- .github/workflows/sync-empty-turn-fix.yml | 141 ---------------------- 1 file changed, 141 deletions(-) delete mode 100644 .github/workflows/sync-empty-turn-fix.yml diff --git a/.github/workflows/sync-empty-turn-fix.yml b/.github/workflows/sync-empty-turn-fix.yml deleted file mode 100644 index d15e1c9..0000000 --- a/.github/workflows/sync-empty-turn-fix.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Implement empty-turn guard - -on: - push: - branches: [fix/empty-assistant-turn-loop] - paths: - - .github/workflows/sync-empty-turn-fix.yml - -permissions: - contents: write - -jobs: - implement: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: fix/empty-assistant-turn-loop - - uses: actions/setup-node@v6 - with: - node-version: 24 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: npm install - - name: Apply focused runtime patch - shell: bash - run: | - node <<'NODE' - const fs = require('node:fs') - - function read(file) { return fs.readFileSync(file, 'utf8') } - function write(file, value) { fs.writeFileSync(file, value) } - function replaceOnce(file, before, after) { - const current = read(file) - const first = current.indexOf(before) - if (first < 0) throw new Error(`missing patch marker in ${file}: ${before.slice(0, 100)}`) - if (current.indexOf(before, first + before.length) >= 0) throw new Error(`ambiguous patch marker in ${file}`) - write(file, current.slice(0, first) + after + current.slice(first + before.length)) - } - function replaceBlock(file, startMarker, endMarker, replacement) { - const current = read(file) - const start = current.indexOf(startMarker) - if (start < 0) throw new Error(`missing start marker in ${file}`) - const end = current.indexOf(endMarker, start) - if (end < 0) throw new Error(`missing end marker in ${file}`) - write(file, current.slice(0, start) + replacement + current.slice(end)) - } - - const emptyTurnFile = 'src/source/runtime/empty-turn.js' - write(emptyTurnFile, `import { actionKind } from "../core/jobs.js"\n\nexport const DEFAULT_MAX_EMPTY_TURNS = 2\n\nexport function guardsEmptyAssistantTurn(job) {\n const kind = actionKind(job?.action, job || {})\n return kind === "prompt" || kind === "goal"\n}\n\nexport function emptyTurnLimit(job) {\n const configured = Number(job?.maxEmptyTurns || 0)\n if (Number.isFinite(configured) && configured > 0) return Math.max(1, Math.floor(configured))\n return DEFAULT_MAX_EMPTY_TURNS\n}\n\nexport function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {\n const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0)\n const currentCount = Number(job?.runCount || 0)\n if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1)\n\n if (Number.isFinite(Number(active?.previousLastRunAt))) job.lastRunAt = Number(active.previousLastRunAt)\n if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {\n job.enabled = true\n }\n\n job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1\n job.lastEmptyTurnAt = Number(timestamp) || Date.now()\n job.lastFailureReason = "empty_turn"\n\n const limit = emptyTurnLimit(job)\n const paused = job.emptyTurnCount >= limit\n if (paused) {\n job.paused = true\n delete job.runNowRequestedAt\n } else {\n job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now())\n }\n return { job, paused, count: job.emptyTurnCount, limit }\n}\n\nexport function clearEmptyAssistantTurnStreak(job) {\n if (!job) return job\n job.emptyTurnCount = 0\n if (job.lastFailureReason === "empty_turn") delete job.lastFailureReason\n return job\n}\n`) - - const hostFile = 'src/source/opencode/host.js' - replaceBlock( - hostFile, - 'export async function activeRunCompletionFromMessages', - 'export async function resolveCompactionModel', - `export function assistantMessageHasMeaningfulActivity(message) {\n const parts = Array.isArray(message?.parts) ? message.parts : []\n for (const part of parts) {\n if (!part || typeof part !== "object") continue\n if (part.type === "text" && typeof part.text === "string" && part.text.trim()) return true\n if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) return true\n }\n const info = message?.info || message || {}\n return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim())\n}\n\nexport async function activeRunCompletionFromMessages(directory, client, sessionID, active) {\n const messages = await readRecentSessionMessages(client, sessionID, directory)\n if (!messages) return "unknown"\n const ordered = orderedSessionMessages(messages)\n const tail = ordered.at(-1)\n const info = tail?.info || tail\n if (!info || info.role !== "assistant") return "incomplete"\n const completed = Number(info?.time?.completed || 0)\n const created = Number(info?.time?.created || 0)\n if (!Number.isFinite(completed) || completed <= 0) return "incomplete"\n const startedAt = Number(active?.startedAt || 0)\n if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"\n\n const relevant = ordered.filter((message) => {\n const candidate = message?.info || message || {}\n if (candidate.role !== "assistant") return false\n if (startedAt <= 0) return true\n const candidateCreated = Number(candidate?.time?.created || 0)\n const candidateCompleted = Number(candidate?.time?.completed || 0)\n return candidateCreated >= startedAt || candidateCompleted >= startedAt\n })\n return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty"\n}\n\n` - ) - - const statusFile = 'src/source/runtime/session-status.js' - replaceOnce( - statusFile, - `function nonNegativeNumber(value, fallback) {\n const number = Number(value)\n return Number.isFinite(number) && number >= 0 ? number : fallback\n}\n`, - `function nonNegativeNumber(value, fallback) {\n const number = Number(value)\n return Number.isFinite(number) && number >= 0 ? number : fallback\n}\n\nfunction settledAssistantCompletion(value) {\n return value === "completed" || value === "empty"\n}\n` - ) - replaceOnce(statusFile, ' if (completion === "completed") return true\n', ' if (settledAssistantCompletion(completion)) return true\n') - replaceOnce(statusFile, ' if (completion !== "completed") return false\n', ' if (!settledAssistantCompletion(completion)) return false\n') - replaceOnce( - statusFile, - ' if (completion === "completed" || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {\n', - ' if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {\n' - ) - replaceOnce( - statusFile, - ` await appendLoopLog(\n directory,\n completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery",\n logDetails,\n )\n`, - ` const recoveryEvent = completion === "empty"\n ? "status-message-empty-recovery"\n : completion === "completed"\n ? "status-message-complete-recovery"\n : "status-stale-recovery"\n await appendLoopLog(directory, recoveryEvent, logDetails)\n` - ) - replaceOnce( - statusFile, - ' readLiveSessionStatus,\n sessionStatusType,\n', - ' readLiveSessionStatus,\n activeRunCompletion: activeRunCompletionFromMessages,\n sessionStatusType,\n' - ) - - const executorFile = 'src/source/runtime/executor.js' - replaceOnce( - executorFile, - 'import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"\n', - 'import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"\nimport { guardsEmptyAssistantTurn, refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "./empty-turn.js"\n' - ) - replaceOnce( - executorFile, - ' canFinalizeActiveRun,\n sessionStatusType,\n', - ' canFinalizeActiveRun,\n activeRunCompletion,\n sessionStatusType,\n' - ) - replaceBlock( - executorFile, - ' async function finalizeActiveRun', - '\n\n const fireAction = actionDispatcher.fireAction', - ` async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {\n const active = activeRuns.get(sessionID)\n if (!active) return\n if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false\n const completion = await activeRunCompletion(directory, client, sessionID, active)\n const recoveredStale = staleActiveRun(sessionID)\n if (active.compactionOnly) {\n const pending = compactionRuntime.getPending(sessionID)\n clearActiveRun(sessionID)\n clearSessionStatus(sessionID)\n await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {\n sessionID,\n job: active.job?.name || active.jobId,\n startedAt: active.startedAt,\n nativeEvent: Boolean(pending?.completedAt),\n })\n await scheduleDueWork(directory, client, sessionID)\n return true\n }\n\n clearActiveRun(sessionID)\n const state = await readState(directory, sessionID)\n let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)\n if (!job) return\n job.lastFinishedAt = now()\n\n if (completion === "empty" && guardsEmptyAssistantTurn(job)) {\n const empty = refundEmptyAssistantTurn(job, active, now())\n state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)\n await writeState(directory, sessionID, state)\n await appendLoopLog(directory, "empty-assistant-turn", {\n sessionID,\n job: job.name || job.id,\n count: empty.count,\n limit: empty.limit,\n paused: empty.paused,\n refunded: true,\n })\n if (empty.paused) {\n await notifyJob(directory, job, "empty_turn")\n await toast(client, `Loop paused after ${empty.count} consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.`, "warning")\n await scheduleDueWork(directory, client, sessionID)\n } else {\n await toast(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning")\n await scheduleDueWork(directory, client, sessionID, busyRetryMs)\n }\n return true\n }\n\n clearEmptyAssistantTurnStreak(job)\n if (recoveredStale) {\n await appendLoopLog(directory, "active-stale-recovery", {\n sessionID,\n job: job.name || job.id,\n startedAt: active.startedAt,\n })\n }\n\n await finalizationRuntime.finalizeJob(directory, client, sessionID, state, job, active.job)\n return true\n }` - ) - - const testFile = 'scripts/empty-turn-recovery-test.mjs' - write(testFile, `import assert from "node:assert/strict"\nimport { activeRunCompletionFromMessages, assistantMessageHasMeaningfulActivity } from "../src/source/opencode/host.js"\nimport { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"\nimport { createLoopExecutor } from "../src/source/runtime/executor.js"\nimport { refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "../src/source/runtime/empty-turn.js"\nimport { clearSessionActivity } from "../src/source/runtime/session-activity.js"\n\nconst blank = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [] }\nconst whitespace = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: " \\n" }] }\nconst text = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: "done" }] }\nconst tool = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "tool", callID: "call-1", state: { status: "completed" } }] }\nassert.equal(assistantMessageHasMeaningfulActivity(blank), false)\nassert.equal(assistantMessageHasMeaningfulActivity(whitespace), false)\nassert.equal(assistantMessageHasMeaningfulActivity(text), true)\nassert.equal(assistantMessageHasMeaningfulActivity(tool), true)\n\nlet hostMessages = [blank]\nconst hostClient = { session: { messages: async () => ({ data: hostMessages }) } }\nassert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-empty", { startedAt: 100 }), "empty")\nhostMessages = [tool, { info: { role: "assistant", time: { created: 140, completed: 150 } }, parts: [] }]\nassert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-tool-then-blank", { startedAt: 100 }), "completed", "tool activity earlier in the same logical run must prevent a false empty classification")\nhostMessages = [text]\nassert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-text", { startedAt: 100 }), "completed")\n\nconst pureJob = { id: "pure", enabled: false, paused: false, runCount: 1, maxRuns: 1, lastRunAt: 50 }\nlet pure = refundEmptyAssistantTurn(pureJob, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 200)\nassert.equal(pure.job.runCount, 0)\nassert.equal(pure.job.enabled, true)\nassert.equal(pure.job.lastRunAt, 10)\nassert.equal(pure.job.runNowRequestedAt, 200)\nassert.equal(pure.job.emptyTurnCount, 1)\nassert.equal(pure.paused, false)\npure = refundEmptyAssistantTurn(pure.job, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 300)\nassert.equal(pure.paused, true)\nassert.equal(pure.job.paused, true)\nassert.equal(pure.job.runNowRequestedAt, undefined)\nclearEmptyAssistantTurnStreak(pure.job)\nassert.equal(pure.job.emptyTurnCount, 0)\nassert.equal(pure.job.lastFailureReason, undefined)\n\nconst statusLogs = []\nconst statusActive = new Map([["status-empty", { jobId: "j", job: { id: "j" }, startedAt: 100 }]])\nconst statusRuntime = createSessionStatusRuntime({\n activeRuns: statusActive,\n now: () => 10_000,\n sessionStatusCacheMs: 0,\n activeRunCompletionFromMessages: async () => "empty",\n appendLoopLog: async (...args) => statusLogs.push(args),\n})\nconst statusClient = { session: { status: async () => ({ data: { "status-empty": { type: "busy" } } }) } }\nassert.equal(await statusRuntime.sessionStatusType(statusClient, "status-empty", "/repo"), "idle", "a host-busy tail that is already an empty completed assistant turn must settle so the empty-turn guard can run")\nassert.ok(statusLogs.some((entry) => entry[1] === "status-message-empty-recovery"))\nclearSessionActivity("status-empty")\n\nlet clock = 1_000\nlet outcome = "empty"\nconst states = new Map()\nconst schedules = []\nconst toasts = []\nconst notifications = []\nconst checkpoints = []\nconst key = (directory, sessionID) => `${directory}:${sessionID}`\nconst clone = (value) => JSON.parse(JSON.stringify(value))\nconst workspace = {\n buildPrompt: async () => "continue",\n ensureBranch: async (_directory, job) => job,\n watchChanged: async () => false,\n untilReached: async () => false,\n createCheckpoint: async (...args) => checkpoints.push(args),\n}\nconst goalPolicy = {\n runGoalChecks: async (_directory, _sessionID, job) => job,\n applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,\n}\nconst scheduler = {\n rememberSession: () => {},\n scheduleDueWork: async (...args) => schedules.push(args),\n}\nconst executor = createLoopExecutor({\n workspace,\n goalPolicy,\n scheduler,\n now: () => clock,\n readState: async (directory, sessionID) => clone(states.get(key(directory, sessionID)) || { jobs: [] }),\n writeState: async (directory, sessionID, state) => states.set(key(directory, sessionID), clone(state)),\n appendLoopLog: async () => {},\n runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),\n notifyJob: async (...args) => notifications.push(args),\n toast: async (...args) => toasts.push(args),\n fireSdk: () => Promise.resolve({}),\n compactSession: async () => true,\n activeRunCompletionFromMessages: async () => outcome,\n busyRetryMs: 5_000,\n})\nconst client = { session: { status: async () => ({ data: {} }), prompt: async () => ({ data: {} }), abort: async () => ({ data: {} }) } }\nconst directory = "/repo"\nconst sessionID = "empty-run"\nstates.set(key(directory, sessionID), { jobs: [{\n id: "job", name: "job", action: "devam et", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 1, maxRuntimeMs: 0, timeoutMs: 0,\n}] })\n\nawait executor.maybeRunDueJobs(directory, client, sessionID)\nassert.equal(states.get(key(directory, sessionID)).jobs[0].runCount, 1)\nassert.equal(states.get(key(directory, sessionID)).jobs[0].enabled, false, "max-runs is provisionally consumed at dispatch")\nclock += 100\nassert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true)\nlet persisted = states.get(key(directory, sessionID)).jobs[0]\nassert.equal(persisted.runCount, 0, "empty completed turn must refund runCount")\nassert.equal(persisted.enabled, true, "empty max-runs attempt must be re-enabled")\nassert.equal(persisted.emptyTurnCount, 1)\nassert.equal(persisted.paused, false)\nassert.equal(checkpoints.length, 0, "empty turn must not run successful finalization/checkpoint work")\nassert.ok(schedules.some((entry) => entry[2] === sessionID && entry[3] === 5_000), "first empty turn retries with bounded delay")\n\nexecutor.markSessionStatus(sessionID, "idle", clock)\nawait executor.maybeRunDueJobs(directory, client, sessionID)\nclock += 100\nassert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true)\npersisted = states.get(key(directory, sessionID)).jobs[0]\nassert.equal(persisted.runCount, 0)\nassert.equal(persisted.emptyTurnCount, 2)\nassert.equal(persisted.paused, true, "second consecutive empty completed turn must fail safe instead of looping forever")\nassert.ok(notifications.some((entry) => entry[1]?.id === "job" && entry[2] === "empty_turn"))\nassert.ok(toasts.some((entry) => /paused after 2 consecutive completed assistant turns/i.test(String(entry[1]))))\n\nconst recoverySession = "empty-then-success"\noutcome = "empty"\nstates.set(key(directory, recoverySession), { jobs: [{\n id: "recover", name: "recover", action: "continue", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 0, maxRuntimeMs: 0, timeoutMs: 0,\n}] })\nawait executor.maybeRunDueJobs(directory, client, recoverySession)\nclock += 100\nawait executor.finalizeActiveRun(directory, client, recoverySession)\nassert.equal(states.get(key(directory, recoverySession)).jobs[0].emptyTurnCount, 1)\nexecutor.markSessionStatus(recoverySession, "idle", clock)\noutcome = "completed"\nawait executor.maybeRunDueJobs(directory, client, recoverySession)\nclock += 100\nawait executor.finalizeActiveRun(directory, client, recoverySession)\nconst recovered = states.get(key(directory, recoverySession)).jobs[0]\nassert.equal(recovered.emptyTurnCount, 0, "a meaningful completed turn resets the consecutive empty streak")\nassert.equal(recovered.lastFailureReason, undefined)\nassert.equal(recovered.runCount, 1)\nassert.ok(checkpoints.some((entry) => entry[2]?.id === "recover"), "meaningful completion still follows normal finalization")\n\nexecutor.disposeSession(sessionID)\nexecutor.disposeSession(recoverySession)\nclearSessionActivity(sessionID)\nclearSessionActivity(recoverySession)\nconsole.log("empty-turn recovery tests passed")\n`) - - const packageFile = 'package.json' - let pkg = read(packageFile) - pkg = pkg.replace( - 'node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/terminal-guard.js', - 'node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js' - ) - pkg = pkg.replace( - 'node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/network-recovery-test.mjs', - 'node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs' - ) - pkg = pkg.replace( - 'node scripts/session-status-idle-recovery-test.mjs && node scripts/network-recovery-test.mjs', - 'node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs' - ) - write(packageFile, pkg) - NODE - - name: Validate source and regression suite - run: | - npm run check - npm test - - name: Regenerate committed stable bundle - run: | - npm run build:plugin - node --check src/index.js - node scripts/empty-turn-recovery-test.mjs - - name: Commit implementation and generated bundle - shell: bash - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src/source/opencode/host.js src/source/runtime/session-status.js src/source/runtime/executor.js src/source/runtime/empty-turn.js scripts/empty-turn-recovery-test.mjs package.json src/index.js - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "fix: stop empty assistant turn loops" - git push origin HEAD:fix/empty-assistant-turn-loop From c1f7ae3b0a8566bcca3ee0924e589508fa57a3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:33:08 +0300 Subject: [PATCH 12/15] ci: remove temporary empty-turn repair workflow --- .../workflows/repair-empty-turn-workflow.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/repair-empty-turn-workflow.yml diff --git a/.github/workflows/repair-empty-turn-workflow.yml b/.github/workflows/repair-empty-turn-workflow.yml deleted file mode 100644 index 554e08b..0000000 --- a/.github/workflows/repair-empty-turn-workflow.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Repair empty-turn implementation workflow - -on: - push: - branches: [fix/empty-assistant-turn-loop] - paths: - - .github/workflows/repair-empty-turn-workflow.yml - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: fix/empty-assistant-turn-loop - - uses: actions/setup-node@v6 - with: - node-version: 24 - - name: Remove nested template literals from patch generator - shell: bash - run: | - node <<'NODE' - const fs = require('node:fs') - const file = '.github/workflows/sync-empty-turn-fix.yml' - let text = fs.readFileSync(file, 'utf8') - const brokenToast = ' await toast(client, `Loop paused after ${empty.count} consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.`, "warning")' - const fixedToast = ' await toast(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning")' - const brokenKey = 'const key = (directory, sessionID) => `${directory}:${sessionID}`' - const fixedKey = 'const key = (directory, sessionID) => String(directory) + ":" + String(sessionID)' - if (!text.includes(brokenToast)) throw new Error('broken toast marker not found') - if (!text.includes(brokenKey)) throw new Error('broken key marker not found') - text = text.replace(brokenToast, fixedToast).replace(brokenKey, fixedKey) - fs.writeFileSync(file, text) - NODE - - name: Commit repaired implementation workflow - shell: bash - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add .github/workflows/sync-empty-turn-fix.yml - git commit -m "ci: fix empty-turn patch generator quoting" - git push origin HEAD:fix/empty-assistant-turn-loop From d64a716b1f91c683dd685eca2c08572149a18231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:33:14 +0300 Subject: [PATCH 13/15] ci: remove temporary empty-turn validation workflow --- .github/workflows/run-empty-turn-fix.yml | 47 ------------------------ 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/run-empty-turn-fix.yml diff --git a/.github/workflows/run-empty-turn-fix.yml b/.github/workflows/run-empty-turn-fix.yml deleted file mode 100644 index 219b1ff..0000000 --- a/.github/workflows/run-empty-turn-fix.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Run empty-turn implementation - -on: - push: - branches: [fix/empty-assistant-turn-loop] - paths: - - .github/workflows/run-empty-turn-fix.yml - -permissions: - contents: write - -# validation-pass: 3 -jobs: - implement: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: fix/empty-assistant-turn-loop - - uses: actions/setup-node@v6 - with: - node-version: 24 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: npm install - - name: Apply empty-turn runtime patch - run: | - node scripts/ci-apply-empty-turn-fix.cjs - node scripts/ci-fix-empty-turn-contract.cjs - - name: Validate full source suite - run: | - npm run check - npm test - - name: Regenerate and validate committed bundle - run: | - npm run build:plugin - node --check src/index.js - node scripts/empty-turn-recovery-test.mjs - - name: Commit verified product changes - shell: bash - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src/source/opencode/host.js src/source/runtime/session-status.js src/source/runtime/executor.js src/source/runtime/empty-turn.js scripts/empty-turn-recovery-test.mjs scripts/host-adapter-contract-test.mjs package.json src/index.js - git commit -m "fix: stop empty assistant turn loops" - git push origin HEAD:fix/empty-assistant-turn-loop From f0cab2ec798120e3c6d2af6b0412266f68a06b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:33:22 +0300 Subject: [PATCH 14/15] ci: remove temporary empty-turn patch applicator --- scripts/ci-apply-empty-turn-fix.cjs | 408 ---------------------------- 1 file changed, 408 deletions(-) delete mode 100644 scripts/ci-apply-empty-turn-fix.cjs diff --git a/scripts/ci-apply-empty-turn-fix.cjs b/scripts/ci-apply-empty-turn-fix.cjs deleted file mode 100644 index bb42c04..0000000 --- a/scripts/ci-apply-empty-turn-fix.cjs +++ /dev/null @@ -1,408 +0,0 @@ -const fs = require('node:fs') - -function read(file) { - return fs.readFileSync(file, 'utf8') -} - -function write(file, value) { - fs.writeFileSync(file, value) -} - -function replaceOnce(file, before, after) { - const current = read(file) - const first = current.indexOf(before) - if (first < 0) throw new Error(`missing patch marker in ${file}: ${before.slice(0, 120)}`) - if (current.indexOf(before, first + before.length) >= 0) throw new Error(`ambiguous patch marker in ${file}`) - write(file, current.slice(0, first) + after + current.slice(first + before.length)) -} - -function replaceBlock(file, startMarker, endMarker, replacement) { - const current = read(file) - const start = current.indexOf(startMarker) - if (start < 0) throw new Error(`missing start marker in ${file}`) - const end = current.indexOf(endMarker, start) - if (end < 0) throw new Error(`missing end marker in ${file}`) - write(file, current.slice(0, start) + replacement + current.slice(end)) -} - -write('src/source/runtime/empty-turn.js', `import { actionKind } from "../core/jobs.js" - -export const DEFAULT_MAX_EMPTY_TURNS = 2 - -export function guardsEmptyAssistantTurn(job) { - const kind = actionKind(job?.action, job || {}) - return kind === "prompt" || kind === "goal" -} - -export function emptyTurnLimit(job) { - const configured = Number(job?.maxEmptyTurns || 0) - if (Number.isFinite(configured) && configured > 0) return Math.max(1, Math.floor(configured)) - return DEFAULT_MAX_EMPTY_TURNS -} - -export function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) { - const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0) - const currentCount = Number(job?.runCount || 0) - if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1) - - if (Number.isFinite(Number(active?.previousLastRunAt))) job.lastRunAt = Number(active.previousLastRunAt) - if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) { - job.enabled = true - } - - job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1 - job.lastEmptyTurnAt = Number(timestamp) || Date.now() - job.lastFailureReason = "empty_turn" - - const limit = emptyTurnLimit(job) - const paused = job.emptyTurnCount >= limit - if (paused) { - job.paused = true - delete job.runNowRequestedAt - } else { - job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now()) - } - return { job, paused, count: job.emptyTurnCount, limit } -} - -export function clearEmptyAssistantTurnStreak(job) { - if (!job) return job - job.emptyTurnCount = 0 - if (job.lastFailureReason === "empty_turn") delete job.lastFailureReason - return job -} -`) - -replaceBlock( - 'src/source/opencode/host.js', - 'export async function activeRunCompletionFromMessages', - 'export async function resolveCompactionModel', - `export function assistantMessageHasMeaningfulActivity(message) { - const parts = Array.isArray(message?.parts) ? message.parts : [] - for (const part of parts) { - if (!part || typeof part !== "object") continue - if (part.type === "text" && typeof part.text === "string" && part.text.trim()) return true - if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) return true - } - const info = message?.info || message || {} - return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim()) -} - -export async function activeRunCompletionFromMessages(directory, client, sessionID, active) { - const messages = await readRecentSessionMessages(client, sessionID, directory) - if (!messages) return "unknown" - const ordered = orderedSessionMessages(messages) - const tail = ordered.at(-1) - const info = tail?.info || tail - if (!info || info.role !== "assistant") return "incomplete" - const completed = Number(info?.time?.completed || 0) - const created = Number(info?.time?.created || 0) - if (!Number.isFinite(completed) || completed <= 0) return "incomplete" - const startedAt = Number(active?.startedAt || 0) - if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete" - - const relevant = ordered.filter((message) => { - const candidate = message?.info || message || {} - if (candidate.role !== "assistant") return false - if (startedAt <= 0) return true - const candidateCreated = Number(candidate?.time?.created || 0) - const candidateCompleted = Number(candidate?.time?.completed || 0) - return candidateCreated >= startedAt || candidateCompleted >= startedAt - }) - return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty" -} - -` -) - -const statusFile = 'src/source/runtime/session-status.js' -replaceOnce( - statusFile, - `function nonNegativeNumber(value, fallback) { - const number = Number(value) - return Number.isFinite(number) && number >= 0 ? number : fallback -} -`, - `function nonNegativeNumber(value, fallback) { - const number = Number(value) - return Number.isFinite(number) && number >= 0 ? number : fallback -} - -function settledAssistantCompletion(value) { - return value === "completed" || value === "empty" -} -` -) -replaceOnce(statusFile, ' if (completion === "completed") return true\n', ' if (settledAssistantCompletion(completion)) return true\n') -replaceOnce(statusFile, ' if (completion !== "completed") return false\n', ' if (!settledAssistantCompletion(completion)) return false\n') -replaceOnce( - statusFile, - ' if (completion === "completed" || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {\n', - ' if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {\n' -) -replaceOnce( - statusFile, - ` await appendLoopLog( - directory, - completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", - logDetails, - ) -`, - ` const recoveryEvent = completion === "empty" - ? "status-message-empty-recovery" - : completion === "completed" - ? "status-message-complete-recovery" - : "status-stale-recovery" - await appendLoopLog(directory, recoveryEvent, logDetails) -` -) -replaceOnce( - statusFile, - ' readLiveSessionStatus,\n sessionStatusType,\n', - ' readLiveSessionStatus,\n activeRunCompletion: activeRunCompletionFromMessages,\n sessionStatusType,\n' -) - -const executorFile = 'src/source/runtime/executor.js' -replaceOnce( - executorFile, - 'import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"\n', - 'import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"\nimport { guardsEmptyAssistantTurn, refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "./empty-turn.js"\n' -) -replaceOnce( - executorFile, - ' canFinalizeActiveRun,\n sessionStatusType,\n', - ' canFinalizeActiveRun,\n activeRunCompletion,\n sessionStatusType,\n' -) -replaceBlock( - executorFile, - ' async function finalizeActiveRun', - '\n\n const fireAction = actionDispatcher.fireAction', - ` async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) { - const active = activeRuns.get(sessionID) - if (!active) return - if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false - const completion = await activeRunCompletion(directory, client, sessionID, active) - const recoveredStale = staleActiveRun(sessionID) - if (active.compactionOnly) { - const pending = compactionRuntime.getPending(sessionID) - clearActiveRun(sessionID) - clearSessionStatus(sessionID) - await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", { - sessionID, - job: active.job?.name || active.jobId, - startedAt: active.startedAt, - nativeEvent: Boolean(pending?.completedAt), - }) - await scheduleDueWork(directory, client, sessionID) - return true - } - - clearActiveRun(sessionID) - const state = await readState(directory, sessionID) - let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId) - if (!job) return - job.lastFinishedAt = now() - - if (completion === "empty" && guardsEmptyAssistantTurn(job)) { - const empty = refundEmptyAssistantTurn(job, active, now()) - state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate) - await writeState(directory, sessionID, state) - await appendLoopLog(directory, "empty-assistant-turn", { - sessionID, - job: job.name || job.id, - count: empty.count, - limit: empty.limit, - paused: empty.paused, - refunded: true, - }) - if (empty.paused) { - await notifyJob(directory, job, "empty_turn") - await toast(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning") - await scheduleDueWork(directory, client, sessionID) - } else { - await toast(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning") - await scheduleDueWork(directory, client, sessionID, busyRetryMs) - } - return true - } - - if (completion === "completed") clearEmptyAssistantTurnStreak(job) - if (recoveredStale) { - await appendLoopLog(directory, "active-stale-recovery", { - sessionID, - job: job.name || job.id, - startedAt: active.startedAt, - }) - } - - await finalizationRuntime.finalizeJob(directory, client, sessionID, state, job, active.job) - return true - }` -) - -write('scripts/empty-turn-recovery-test.mjs', `import assert from "node:assert/strict" -import { activeRunCompletionFromMessages, assistantMessageHasMeaningfulActivity } from "../src/source/opencode/host.js" -import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js" -import { createLoopExecutor } from "../src/source/runtime/executor.js" -import { refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "../src/source/runtime/empty-turn.js" -import { clearSessionActivity } from "../src/source/runtime/session-activity.js" - -const blank = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [] } -const whitespace = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: " \\n" }] } -const text = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: "done" }] } -const tool = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "tool", callID: "call-1", state: { status: "completed" } }] } -assert.equal(assistantMessageHasMeaningfulActivity(blank), false) -assert.equal(assistantMessageHasMeaningfulActivity(whitespace), false) -assert.equal(assistantMessageHasMeaningfulActivity(text), true) -assert.equal(assistantMessageHasMeaningfulActivity(tool), true) - -let hostMessages = [blank] -const hostClient = { session: { messages: async () => ({ data: hostMessages }) } } -assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-empty", { startedAt: 100 }), "empty") -hostMessages = [tool, { info: { role: "assistant", time: { created: 140, completed: 150 } }, parts: [] }] -assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-tool-then-blank", { startedAt: 100 }), "completed", "tool activity earlier in the same logical run must prevent a false empty classification") -hostMessages = [text] -assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-text", { startedAt: 100 }), "completed") - -const pureJob = { id: "pure", enabled: false, paused: false, runCount: 1, maxRuns: 1, lastRunAt: 50 } -let pure = refundEmptyAssistantTurn(pureJob, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 200) -assert.equal(pure.job.runCount, 0) -assert.equal(pure.job.enabled, true) -assert.equal(pure.job.lastRunAt, 10) -assert.equal(pure.job.runNowRequestedAt, 200) -assert.equal(pure.job.emptyTurnCount, 1) -assert.equal(pure.paused, false) -pure = refundEmptyAssistantTurn(pure.job, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 300) -assert.equal(pure.paused, true) -assert.equal(pure.job.paused, true) -assert.equal(pure.job.runNowRequestedAt, undefined) -clearEmptyAssistantTurnStreak(pure.job) -assert.equal(pure.job.emptyTurnCount, 0) -assert.equal(pure.job.lastFailureReason, undefined) - -const statusLogs = [] -const statusActive = new Map([["status-empty", { jobId: "j", job: { id: "j" }, startedAt: 100 }]]) -const statusRuntime = createSessionStatusRuntime({ - activeRuns: statusActive, - now: () => 10_000, - sessionStatusCacheMs: 0, - activeRunCompletionFromMessages: async () => "empty", - appendLoopLog: async (...args) => statusLogs.push(args), -}) -const statusClient = { session: { status: async () => ({ data: { "status-empty": { type: "busy" } } }) } } -assert.equal(await statusRuntime.sessionStatusType(statusClient, "status-empty", "/repo"), "idle", "a host-busy tail that is already an empty completed assistant turn must settle so the empty-turn guard can run") -assert.ok(statusLogs.some((entry) => entry[1] === "status-message-empty-recovery")) -clearSessionActivity("status-empty") - -let clock = 1_000 -let outcome = "empty" -const states = new Map() -const schedules = [] -const toasts = [] -const notifications = [] -const checkpoints = [] -const key = (directory, sessionID) => String(directory) + ":" + String(sessionID) -const clone = (value) => JSON.parse(JSON.stringify(value)) -const workspace = { - buildPrompt: async () => "continue", - ensureBranch: async (_directory, job) => job, - watchChanged: async () => false, - untilReached: async () => false, - createCheckpoint: async (...args) => checkpoints.push(args), -} -const goalPolicy = { - runGoalChecks: async (_directory, _sessionID, job) => job, - applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job, -} -const scheduler = { - rememberSession: () => {}, - scheduleDueWork: async (...args) => schedules.push(args), -} -const executor = createLoopExecutor({ - workspace, - goalPolicy, - scheduler, - now: () => clock, - readState: async (directory, sessionID) => clone(states.get(key(directory, sessionID)) || { jobs: [] }), - writeState: async (directory, sessionID, state) => states.set(key(directory, sessionID), clone(state)), - appendLoopLog: async () => {}, - runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }), - notifyJob: async (...args) => notifications.push(args), - toast: async (...args) => toasts.push(args), - fireSdk: () => Promise.resolve({}), - compactSession: async () => true, - activeRunCompletionFromMessages: async () => outcome, - busyRetryMs: 5_000, -}) -const client = { session: { status: async () => ({ data: {} }), prompt: async () => ({ data: {} }), abort: async () => ({ data: {} }) } } -const directory = "/repo" -const sessionID = "empty-run" -states.set(key(directory, sessionID), { jobs: [{ - id: "job", name: "job", action: "devam et", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 1, maxRuntimeMs: 0, timeoutMs: 0, -}] }) - -await executor.maybeRunDueJobs(directory, client, sessionID) -assert.equal(states.get(key(directory, sessionID)).jobs[0].runCount, 1) -assert.equal(states.get(key(directory, sessionID)).jobs[0].enabled, false, "max-runs is provisionally consumed at dispatch") -clock += 100 -assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true) -let persisted = states.get(key(directory, sessionID)).jobs[0] -assert.equal(persisted.runCount, 0, "empty completed turn must refund runCount") -assert.equal(persisted.enabled, true, "empty max-runs attempt must be re-enabled") -assert.equal(persisted.emptyTurnCount, 1) -assert.equal(persisted.paused, false) -assert.equal(checkpoints.length, 0, "empty turn must not run successful finalization/checkpoint work") -assert.ok(schedules.some((entry) => entry[2] === sessionID && entry[3] === 5_000), "first empty turn retries with bounded delay") - -executor.markSessionStatus(sessionID, "idle", clock) -await executor.maybeRunDueJobs(directory, client, sessionID) -clock += 100 -assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true) -persisted = states.get(key(directory, sessionID)).jobs[0] -assert.equal(persisted.runCount, 0) -assert.equal(persisted.emptyTurnCount, 2) -assert.equal(persisted.paused, true, "second consecutive empty completed turn must fail safe instead of looping forever") -assert.ok(notifications.some((entry) => entry[1]?.id === "job" && entry[2] === "empty_turn")) -assert.ok(toasts.some((entry) => /paused after 2 consecutive completed assistant turns/i.test(String(entry[1])))) - -const recoverySession = "empty-then-success" -outcome = "empty" -states.set(key(directory, recoverySession), { jobs: [{ - id: "recover", name: "recover", action: "continue", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 0, maxRuntimeMs: 0, timeoutMs: 0, -}] }) -await executor.maybeRunDueJobs(directory, client, recoverySession) -clock += 100 -await executor.finalizeActiveRun(directory, client, recoverySession) -assert.equal(states.get(key(directory, recoverySession)).jobs[0].emptyTurnCount, 1) -executor.markSessionStatus(recoverySession, "idle", clock) -outcome = "completed" -await executor.maybeRunDueJobs(directory, client, recoverySession) -clock += 100 -await executor.finalizeActiveRun(directory, client, recoverySession) -const recovered = states.get(key(directory, recoverySession)).jobs[0] -assert.equal(recovered.emptyTurnCount, 0, "a meaningful completed turn resets the consecutive empty streak") -assert.equal(recovered.lastFailureReason, undefined) -assert.equal(recovered.runCount, 1) -assert.ok(checkpoints.some((entry) => entry[2]?.id === "recover"), "meaningful completion still follows normal finalization") - -executor.disposeSession(sessionID) -executor.disposeSession(recoverySession) -clearSessionActivity(sessionID) -clearSessionActivity(recoverySession) -console.log("empty-turn recovery tests passed") -`) - -let pkg = read('package.json') -const checkBefore = 'node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/terminal-guard.js' -const checkAfter = 'node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js' -const testCheckBefore = 'node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/network-recovery-test.mjs' -const testCheckAfter = 'node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs' -const testBefore = 'node scripts/session-status-idle-recovery-test.mjs && node scripts/network-recovery-test.mjs' -const testAfter = 'node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs' -for (const [before, after] of [[checkBefore, checkAfter], [testCheckBefore, testCheckAfter], [testBefore, testAfter]]) { - if (!pkg.includes(before)) throw new Error(`missing package script marker: ${before}`) - pkg = pkg.replace(before, after) -} -write('package.json', pkg) - -console.log('empty-turn source patch applied') From 8d49ad8343f25e43d59c7c293b273c48f1c50b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 27 Aug 2026 03:33:27 +0300 Subject: [PATCH 15/15] ci: remove temporary empty-turn fixture updater --- scripts/ci-fix-empty-turn-contract.cjs | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 scripts/ci-fix-empty-turn-contract.cjs diff --git a/scripts/ci-fix-empty-turn-contract.cjs b/scripts/ci-fix-empty-turn-contract.cjs deleted file mode 100644 index 1d25e2b..0000000 --- a/scripts/ci-fix-empty-turn-contract.cjs +++ /dev/null @@ -1,21 +0,0 @@ -const fs = require('node:fs') -const file = 'scripts/host-adapter-contract-test.mjs' -let text = fs.readFileSync(file, 'utf8') - -const replacements = [ - [ - 'messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },', - 'messagesClient([{ info: { role: "assistant", text: "done", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },', - ], - [ - 'return { data: [{ info: { role: "assistant", time: { created: 10, completed: 20 } } }] }', - 'return { data: [{ info: { role: "assistant", text: "adapter completed", time: { created: 10, completed: 20 } } }] }', - ], -] - -for (const [before, after] of replacements) { - if (!text.includes(before)) throw new Error(`host adapter completion fixture marker not found: ${before}`) - text = text.replace(before, after) -} -fs.writeFileSync(file, text) -console.log('host adapter meaningful-completion fixtures updated')