Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a2fa9c2
test: cover targeted loop-now on real OpenCode host
ByBrawe Aug 18, 2026
6d09866
ci: run bundle gate when host canaries change
ByBrawe Aug 18, 2026
a2ede6b
ci: expose source-built bundle for sync
ByBrawe Aug 18, 2026
964d876
ci: add one-shot generated bundle sync
ByBrawe Aug 18, 2026
66100de
build: sync generated stable plugin
Aug 18, 2026
0c854fd
ci: trigger generated bundle sync
ByBrawe Aug 18, 2026
cf2d246
build: sync generated stable plugin
Aug 18, 2026
34eb82c
ci: remove temporary bundle artifact upload
ByBrawe Aug 18, 2026
1129125
ci: add one-shot loop-now diagnostics patch
ByBrawe Aug 18, 2026
007f26d
test: add loop-now timeout state diagnostics
Aug 18, 2026
d5a005b
ci: trigger loop-now diagnostics patch
ByBrawe Aug 18, 2026
4485a87
ci: retrigger loop-now diagnostics patch
ByBrawe Aug 18, 2026
d087ea8
ci: remove unused diagnostics helper
ByBrawe Aug 18, 2026
8536626
fix: defer stable loop-now dispatch to idle-safe scheduler
ByBrawe Aug 18, 2026
99e730e
test: require idle-safe stable loop-now scheduling
ByBrawe Aug 18, 2026
2d05792
ci: add one-shot generated bundle sync
ByBrawe Aug 18, 2026
5ee1748
ci: trigger generated bundle sync
ByBrawe Aug 18, 2026
1417f9b
build: sync generated stable plugin
Aug 18, 2026
574b5ee
ci: add one-shot comprehensive run-now timing patch
ByBrawe Aug 18, 2026
816d74f
test: wait for idle-safe loop-now scheduling
Aug 18, 2026
ee98f8f
ci: trigger comprehensive run-now timing patch
ByBrawe Aug 18, 2026
a9b8b3c
ci: retrigger comprehensive run-now timing patch
ByBrawe Aug 18, 2026
528aba2
ci: remove temporary comprehensive patch helper
ByBrawe Aug 18, 2026
5462280
chore: add one-shot stale-busy test patch
ByBrawe Aug 18, 2026
a7fee4f
test: align stale-busy recovery with scheduler cache
github-actions[bot] Aug 18, 2026
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
3 changes: 2 additions & 1 deletion .github/workflows/bundle-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "src/source/**"
- "scripts/host-*-canary.mjs"
- "package.json"
- ".github/workflows/bundle-gate.yml"
workflow_dispatch:
Expand Down Expand Up @@ -65,4 +66,4 @@ jobs:
run: npm ci

- name: Run generated-bundle regression suite
run: npm test
run: npm test
5 changes: 5 additions & 0 deletions scripts/comprehensive-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`) {
Expand Down Expand Up @@ -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")
Expand Down
66 changes: 54 additions & 12 deletions scripts/host-loop-canary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`)
Expand All @@ -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)
})

Expand All @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -307,26 +325,32 @@ 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
})

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 {}
Expand All @@ -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)
Expand All @@ -354,4 +396,4 @@ async function main() {
main().catch((error) => {
console.error(error?.stack || error)
process.exitCode = 1
})
})
4 changes: 3 additions & 1 deletion scripts/loop-command-handlers-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,16 @@ 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")
}

{
const sessionID = "now-missing"
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")
}

Expand Down
47 changes: 33 additions & 14 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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 + `
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/source/opencode/loop-commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down