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, }