diff --git a/.github/workflows/bundle-gate.yml b/.github/workflows/bundle-gate.yml index e741961..51d00db 100644 --- a/.github/workflows/bundle-gate.yml +++ b/.github/workflows/bundle-gate.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "src/source/**" + - "scripts/host-*-canary.mjs" - "package.json" - ".github/workflows/bundle-gate.yml" workflow_dispatch: @@ -65,4 +66,4 @@ jobs: run: npm ci - name: Run generated-bundle regression suite - run: npm test + run: npm test \ No newline at end of file diff --git a/scripts/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index 4e95125..629b8f3 100644 --- a/scripts/comprehensive-test.mjs +++ b/scripts/comprehensive-test.mjs @@ -138,6 +138,8 @@ async function createHarness(options = {}) { messageHistory, async command(command, argumentsText = "", output = { parts: [] }) { await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output) + // /loop-now persists a request and dispatches through the scheduler's 250ms idle-safe timer. + if (command === "loop-now") await delay(400) return output }, async commandEvent(command, argumentsText = "", messageID = `msg_${Date.now()}_${Math.random()}`) { @@ -477,6 +479,9 @@ async function testStaleBusyUsesCompletedAssistantTail() { { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] }, { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] }, ) + // Let the short busy-status cache expire so the scheduler re-reads live host state + // and can reconcile the completed assistant tail instead of trusting cached busy. + await delay(1_700) await h.command("loop-now", "stale-complete") const state = await h.readState() assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status") diff --git a/scripts/host-loop-canary.mjs b/scripts/host-loop-canary.mjs index 05a1e51..8c28416 100644 --- a/scripts/host-loop-canary.mjs +++ b/scripts/host-loop-canary.mjs @@ -11,6 +11,8 @@ import { fileURLToPath, pathToFileURL } from "node:url" const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const isWindows = process.platform === "win32" const LOOP_OBJECTIVE = "real host loop canary" +const RUN_NOW_NATURAL_OBJECTIVE = "real host loop-now natural canary" +const RUN_NOW_TARGET_OBJECTIVE = "real host loop-now target canary" function resolveOpenCodeBinary() { if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode") @@ -120,8 +122,12 @@ function contentText(content) { return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n") } -function allMessageText(body) { - return (body.messages ?? []).map((message) => contentText(message?.content)).join("\n") +function lastUserMessageText(body) { + const messages = Array.isArray(body?.messages) ? body.messages : [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.role === "user") return contentText(messages[index]?.content) + } + return contentText(messages.at(-1)?.content) } function streamHeaders(res) { @@ -159,7 +165,7 @@ function streamText(res, content, sequence) { } function startProvider() { - const stats = { chatRequests: 0, loopRequests: 0, paths: [] } + const stats = { chatRequests: 0, loopRequests: 0, runNowSequence: [], paths: [] } const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://127.0.0.1") stats.paths.push(`${req.method} ${url.pathname}`) @@ -178,12 +184,22 @@ function startProvider() { for await (const chunk of req) raw += String(chunk) const body = raw ? JSON.parse(raw) : {} stats.chatRequests += 1 - const text = allMessageText(body) + const text = lastUserMessageText(body) if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(LOOP_OBJECTIVE)) { stats.loopRequests += 1 streamText(res, `LOOP_TURN_${stats.loopRequests}`, stats.chatRequests) return } + if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(RUN_NOW_TARGET_OBJECTIVE)) { + stats.runNowSequence.push("target") + streamText(res, "RUN_NOW_TARGET", stats.chatRequests) + return + } + if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(RUN_NOW_NATURAL_OBJECTIVE)) { + stats.runNowSequence.push("natural") + streamText(res, "RUN_NOW_NATURAL", stats.chatRequests) + return + } streamText(res, "OK", stats.chatRequests) }) @@ -210,7 +226,8 @@ async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000) if (await predicate()) return await new Promise((resolve) => setTimeout(resolve, 50)) } - throw new Error(`timed out waiting for ${description}\n${diagnostics()}`) + const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics + throw new Error(`timed out waiting for ${description}\n${detail}`) } function isFetchTimeout(error) { @@ -249,6 +266,7 @@ async function main() { const pluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(pluginEntry)}\n`) await writeFile(path.join(commandDir, "loop.md"), `---\ndescription: Start a host canary loop\nagent: opencode-loop-local\n---\n\nOpenCode Loop local command handled. Reply exactly: OK.\n`) + await writeFile(path.join(commandDir, "loop-now.md"), `---\ndescription: Run a host canary loop now\nagent: opencode-loop-local\n---\n\nOpenCode Loop run-now command handled locally. Reply exactly: OK.\n`) await writeFile(path.join(agentDir, "opencode-loop-local.md"), `---\ndescription: Local Loop command acknowledgement\nmode: primary\npermission:\n "*": deny\n---\n\nReply exactly: OK\n`) await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -307,11 +325,14 @@ async function main() { const sessionID = String(session?.id ?? "") assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`) - const command = api(`/session/${encodeURIComponent(sessionID)}/command`, { + const commandPath = `/session/${encodeURIComponent(sessionID)}/command` + const sendCommand = async (name, argumentsText, timeoutMs = 90_000) => await api(commandPath, { method: "POST", - body: JSON.stringify({ agent: "build", model: "canary/canary", command: "loop", arguments: `0s --max-runs 3 ${LOOP_OBJECTIVE}` }), - signal: AbortSignal.timeout(90_000), - }).catch((error) => { + body: JSON.stringify({ agent: "build", model: "canary/canary", command: name, arguments: argumentsText }), + signal: AbortSignal.timeout(timeoutMs), + }) + + const command = sendCommand("loop", `0s --max-runs 3 ${LOOP_OBJECTIVE}`).catch((error) => { commandError = error return null }) @@ -319,14 +340,17 @@ async function main() { const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`) const diagnostics = async () => { let state = "missing" + let loopLog = "missing" try { state = await readFile(stateFile, "utf8") } catch {} - return `provider=${JSON.stringify(provider.stats)}\ncommandError=${String(commandError ?? "none")}\nstate=${state}\nserver log:\n${serverLog}` + try { loopLog = await readFile(path.join(workspace, ".opencode", "opencode-loop", "loop.log"), "utf8") } catch {} + return `provider=${JSON.stringify(provider.stats)}\ncommandError=${String(commandError ?? "none")}\nstate=${state}\nloop log:\n${loopLog}\nserver log:\n${serverLog}` } await waitFor(() => provider.stats.loopRequests >= 3, "three real autonomous Loop turns", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`) await new Promise((resolve) => setTimeout(resolve, 1_500)) assert.equal(provider.stats.loopRequests, 3, `Loop must stop at --max-runs 3; got extra real-host turn(s)\n${await diagnostics()}`) assert.equal(server.exitCode, null, `OpenCode server exited during canary\n${await diagnostics()}`) + await command let persisted = null try { persisted = JSON.parse(await readFile(stateFile, "utf8")) } catch {} @@ -335,15 +359,33 @@ async function main() { if (loop) assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(loop)}`) } + await sendCommand("loop", `10m --no-now --name natural --multi --max-runs 1 ${RUN_NOW_NATURAL_OBJECTIVE}`) + await sendCommand("loop", `10m --no-now --name target --multi --max-runs 1 ${RUN_NOW_TARGET_OBJECTIVE}`) + await waitFor(async () => { + try { + const state = JSON.parse(await readFile(stateFile, "utf8")) + const names = new Set((state.jobs || []).map((job) => job.name)) + return names.has("natural") && names.has("target") + } catch { + return false + } + }, "two delayed real-host Loop jobs", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`) + + provider.stats.runNowSequence.length = 0 + await sendCommand("loop-now", "target") + await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", diagnostics) + await new Promise((resolve) => setTimeout(resolve, 1_500)) + assert.deepEqual(provider.stats.runNowSequence, ["target"], `loop-now target must not run the earlier delayed natural job\n${await diagnostics()}`) + console.log(JSON.stringify({ ok: true, platform: process.platform, sessionID, loopRequests: provider.stats.loopRequests, + runNowSequence: provider.stats.runNowSequence, chatRequests: provider.stats.chatRequests, commandError: commandError ? String(commandError) : null, }, null, 2)) - void command } finally { await stopProcess(server) await provider.close().catch(() => undefined) @@ -354,4 +396,4 @@ async function main() { main().catch((error) => { console.error(error?.stack || error) process.exitCode = 1 -}) \ No newline at end of file +}) diff --git a/scripts/loop-command-handlers-test.mjs b/scripts/loop-command-handlers-test.mjs index f00cc8d..fd81901 100644 --- a/scripts/loop-command-handlers-test.mjs +++ b/scripts/loop-command-handlers-test.mjs @@ -200,7 +200,8 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD assert.equal(jobs[1].paused, false) assert.equal(jobs[1].runNowRequestedAt, 10_000) assert.deepEqual(h.toasts, [[client, "Marked 1 loop job(s) due now.", "success"]]) - assert.deepEqual(h.forcedRuns, [["/work", client, sessionID]]) + assert.deepEqual(h.due, [["/work", client, sessionID]], "run-now must defer dispatch through the idle-safe scheduler") + assert.equal(h.forcedRuns.length, 0, "run-now must not start a model turn re-entrantly inside command.execute.before") } { @@ -208,6 +209,7 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD const h = harness({ [sessionID]: { jobs: [loopJob("a")] } }) await h.handlers.runNow("/work", {}, sessionID, "missing") assert.deepEqual(h.toasts, [[{}, "Marked 0 loop job(s) due now.", "warning"]]) + assert.equal(h.due.length, 0, "missing run-now target must not schedule unrelated jobs") assert.equal(h.forcedRuns.length, 0, "missing run-now target must not run unrelated due jobs") } diff --git a/src/index.js b/src/index.js index e40aeab..29f7163 100644 --- a/src/index.js +++ b/src/index.js @@ -1698,8 +1698,8 @@ function createLoopCommandHandlers(options = {}) { const state = await readState2(directory, sessionID); const jobs = state.jobs || []; const lines = jobs.length ? jobs.map((job, index) => { - const dueIn = Math.max(0, job.intervalMs - (now2() - (job.lastRunAt || 0))); - const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(","); + const dueIn = Number(job.runNowRequestedAt || 0) > 0 ? 0 : Math.max(0, job.intervalMs - (now2() - (job.lastRunAt || 0))); + const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", Number(job.runNowRequestedAt || 0) > 0 ? "run-now" : undefined, job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(","); return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`; }) : ["No active loop jobs."]; await toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning"); @@ -1738,16 +1738,20 @@ function createLoopCommandHandlers(options = {}) { async function runNow(directory, client, sessionID, args) { const target = String(args || "").trim() || "all"; const state = await readState2(directory, sessionID); + const requestedAt = Math.max(1, Number(now2()) || Date.now()); let count = 0; - for (const [index, job] of (state.jobs || []).entries()) - if (matchJob(job, target, index)) { - job.lastRunAt = 0; - job.paused = false; - count++; - } + for (const [index, job] of (state.jobs || []).entries()) { + if (!matchJob(job, target, index)) + continue; + job.lastRunAt = 0; + job.paused = false; + job.runNowRequestedAt = requestedAt; + count += 1; + } await writeState2(directory, sessionID, state); await toast2(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning"); - await maybeRunDueJobs(directory, client, sessionID, { force: true }); + if (count) + await scheduleDueWork(directory, client, sessionID); } async function doctorLoop(directory, client, sessionID) { const state = await readState2(directory, sessionID); @@ -2005,15 +2009,20 @@ function jobDueAt(job, current = now()) { return Infinity; if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity; + if (Number(job.runNowRequestedAt || 0) > 0) + return current; if (job.watchPaths?.length) return Infinity; - const created = Date.parse(job.createdAt || new Date().toISOString()); - if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs) + const created = Date.parse(job.createdAt || ""); + if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs) return current; if (job.intervalMs === 0) return current; - if (!job.lastRunAt) + if (!job.lastRunAt) { + if (job.immediate === false) + return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0); return current; + } return job.lastRunAt + (job.intervalMs || 0); } function nextDueDelay(state, current = now()) { @@ -2806,7 +2815,7 @@ function createLoopExecutor(options = {}) { }); function dueJobs(state, force = false) { const current = now2(); - return (state.jobs || []).filter((job) => { + const due = (state.jobs || []).filter((job) => { if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false; if (!job.enabled || job.paused) @@ -2815,12 +2824,15 @@ function createLoopExecutor(options = {}) { return false; if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true; + if (Number(job.runNowRequestedAt || 0) > 0) + return true; if (force) return true; if (job.watchPaths?.length) return job.watchTriggered === true; return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs; }); + return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0)); } function clearActiveRun(sessionID) { const active = activeRuns.get(sessionID); @@ -3053,13 +3065,14 @@ ${prompt}`; candidate.watchTriggered = true; } } - const due = dueJobs(state, runOptions.force); + const due = dueJobs(state, Boolean(runOptions.force)); if (!due.length) { await writeState2(directory, sessionID, state); await reschedule(); return; } job = due[0]; + const runNowRequested = Number(job.runNowRequestedAt || 0) > 0; if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) { state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id); await writeState2(directory, sessionID, state); @@ -3087,6 +3100,8 @@ ${prompt}`; } if (job.preflightCommand) { if (job.safe && dangerousShell2(job.preflightCommand)) { + if (runNowRequested) + delete job.runNowRequestedAt; job.paused = true; await writeState2(directory, sessionID, state); await notifyJob2(directory, job, "preflight_blocked"); @@ -3102,6 +3117,8 @@ ${prompt}`; code: preflight.code }); if (preflight.code !== 0) { + if (runNowRequested) + delete job.runNowRequestedAt; job.paused = true; job.failureCount = (job.failureCount || 0) + 1; job.lastPreflightFailure = (job.preflightCommand + ` @@ -3139,6 +3156,8 @@ exit=` + preflight.code + ` await reschedule(busyRetryMs); return; } + if (runNowRequested) + delete job.runNowRequestedAt; job.watchTriggered = false; job.lastRunAt = now2(); job.runCount = (job.runCount || 0) + 1; diff --git a/src/source/opencode/loop-commands.js b/src/source/opencode/loop-commands.js index d54c941..15d107e 100644 --- a/src/source/opencode/loop-commands.js +++ b/src/source/opencode/loop-commands.js @@ -136,7 +136,9 @@ export function createLoopCommandHandlers(options = {}) { } await writeState(directory, sessionID, state) await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning") - if (count) await maybeRunDueJobs(directory, client, sessionID) + // /loop-now is handled in command.execute.before. Do not start a model turn re-entrantly + // inside that hook; schedule the persisted request and let the idle-safe timer dispatch it. + if (count) await scheduleDueWork(directory, client, sessionID) } async function doctorLoop(directory, client, sessionID) {