Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
150 changes: 150 additions & 0 deletions scripts/empty-turn-recovery-test.mjs
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 2 additions & 2 deletions scripts/host-adapter-contract-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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 } } }] }
},
},
}
Expand Down
Loading