From a2fa9c2f63ecc836679939b6e2df23c40a7adbea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:46:22 +0300 Subject: [PATCH 01/25] test: cover targeted loop-now on real OpenCode host --- scripts/host-loop-canary.mjs | 59 ++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/scripts/host-loop-canary.mjs b/scripts/host-loop-canary.mjs index 05a1e51..e625a28 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) }) @@ -249,6 +265,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 +324,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 }) @@ -327,6 +347,7 @@ async function main() { 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 +356,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", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`) + 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 +393,4 @@ async function main() { main().catch((error) => { console.error(error?.stack || error) process.exitCode = 1 -}) \ No newline at end of file +}) From 6d09866a0db01e7ff30981c7e1f5c33ca50591ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:48:07 +0300 Subject: [PATCH 02/25] ci: run bundle gate when host canaries change --- .github/workflows/bundle-gate.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From a2ede6bc0ef3b56c30b82ec6c223ae5f02140030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:50:50 +0300 Subject: [PATCH 03/25] ci: expose source-built bundle for sync --- .github/workflows/bundle-gate.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/bundle-gate.yml b/.github/workflows/bundle-gate.yml index 51d00db..422a525 100644 --- a/.github/workflows/bundle-gate.yml +++ b/.github/workflows/bundle-gate.yml @@ -45,6 +45,14 @@ jobs: - name: Build a single-file stable plugin from source run: npm run build:plugin + - name: Upload generated stable plugin + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: stable-plugin-source-build + path: src/index.js + if-no-files-found: error + - name: Verify generated entry syntax and import run: | node --check src/index.js From 964d8764aa4cc52c0f3a718b5a7a3b012f1759d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:51:59 +0300 Subject: [PATCH 04/25] ci: add one-shot generated bundle sync --- .github/workflows/sync-generated-bundle.yml | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/sync-generated-bundle.yml diff --git a/.github/workflows/sync-generated-bundle.yml b/.github/workflows/sync-generated-bundle.yml new file mode 100644 index 0000000..592a300 --- /dev/null +++ b/.github/workflows/sync-generated-bundle.yml @@ -0,0 +1,40 @@ +name: Sync generated stable bundle + +on: + push: + branches: + - test/stable-loop-now-real-host + paths: + - ".github/workflows/sync-generated-bundle.yml" + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: test/stable-loop-now-real-host + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm ci + - run: npm run build:plugin + - name: Commit generated stable plugin and remove helper + shell: bash + run: | + rm .github/workflows/sync-generated-bundle.yml + git config user.email "opencode-loop-ci@example.invalid" + git config user.name "OpenCode Loop CI" + git add src/index.js .github/workflows/sync-generated-bundle.yml + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "build: sync generated stable plugin" + git push origin HEAD:test/stable-loop-now-real-host From 66100de30fabb40ebfe56605e67f5faadc9d0e46 Mon Sep 17 00:00:00 2001 From: OpenCode Loop CI Date: Tue, 18 Aug 2026 00:52:16 +0000 Subject: [PATCH 05/25] build: sync generated stable plugin --- .github/workflows/sync-generated-bundle.yml | 40 ------------------ src/index.js | 47 +++++++++++++++------ 2 files changed, 33 insertions(+), 54 deletions(-) delete mode 100644 .github/workflows/sync-generated-bundle.yml diff --git a/.github/workflows/sync-generated-bundle.yml b/.github/workflows/sync-generated-bundle.yml deleted file mode 100644 index 592a300..0000000 --- a/.github/workflows/sync-generated-bundle.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Sync generated stable bundle - -on: - push: - branches: - - test/stable-loop-now-real-host - paths: - - ".github/workflows/sync-generated-bundle.yml" - -permissions: - contents: write - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: test/stable-loop-now-real-host - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: npm - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: npm ci - - run: npm run build:plugin - - name: Commit generated stable plugin and remove helper - shell: bash - run: | - rm .github/workflows/sync-generated-bundle.yml - git config user.email "opencode-loop-ci@example.invalid" - git config user.name "OpenCode Loop CI" - git add src/index.js .github/workflows/sync-generated-bundle.yml - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "build: sync generated stable plugin" - git push origin HEAD:test/stable-loop-now-real-host diff --git a/src/index.js b/src/index.js index e40aeab..0a3f7da 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 maybeRunDueJobs(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; From 0c854fddfcbe6337e22e1a4448e7137d14c0ccad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:52:26 +0300 Subject: [PATCH 06/25] ci: trigger generated bundle sync --- .github/workflows/sync-generated-bundle.yml | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/sync-generated-bundle.yml diff --git a/.github/workflows/sync-generated-bundle.yml b/.github/workflows/sync-generated-bundle.yml new file mode 100644 index 0000000..72d6d3a --- /dev/null +++ b/.github/workflows/sync-generated-bundle.yml @@ -0,0 +1,41 @@ +name: Sync generated stable bundle + +# Second push intentionally triggers this one-shot helper after it exists on the branch. +on: + push: + branches: + - test/stable-loop-now-real-host + paths: + - ".github/workflows/sync-generated-bundle.yml" + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: test/stable-loop-now-real-host + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm ci + - run: npm run build:plugin + - name: Commit generated stable plugin and remove helper + shell: bash + run: | + rm .github/workflows/sync-generated-bundle.yml + git config user.email "opencode-loop-ci@example.invalid" + git config user.name "OpenCode Loop CI" + git add src/index.js .github/workflows/sync-generated-bundle.yml + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "build: sync generated stable plugin" + git push origin HEAD:test/stable-loop-now-real-host From cf2d24622dd40f773e2d15e94bfa51e020fc08c0 Mon Sep 17 00:00:00 2001 From: OpenCode Loop CI Date: Tue, 18 Aug 2026 00:52:43 +0000 Subject: [PATCH 07/25] build: sync generated stable plugin --- .github/workflows/sync-generated-bundle.yml | 41 --------------------- 1 file changed, 41 deletions(-) delete mode 100644 .github/workflows/sync-generated-bundle.yml diff --git a/.github/workflows/sync-generated-bundle.yml b/.github/workflows/sync-generated-bundle.yml deleted file mode 100644 index 72d6d3a..0000000 --- a/.github/workflows/sync-generated-bundle.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Sync generated stable bundle - -# Second push intentionally triggers this one-shot helper after it exists on the branch. -on: - push: - branches: - - test/stable-loop-now-real-host - paths: - - ".github/workflows/sync-generated-bundle.yml" - -permissions: - contents: write - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: test/stable-loop-now-real-host - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: npm - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: npm ci - - run: npm run build:plugin - - name: Commit generated stable plugin and remove helper - shell: bash - run: | - rm .github/workflows/sync-generated-bundle.yml - git config user.email "opencode-loop-ci@example.invalid" - git config user.name "OpenCode Loop CI" - git add src/index.js .github/workflows/sync-generated-bundle.yml - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "build: sync generated stable plugin" - git push origin HEAD:test/stable-loop-now-real-host From 34eb82cbe9dbed3eb8bf6c9cc6a0ba5fd9e9919d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:53:46 +0300 Subject: [PATCH 08/25] ci: remove temporary bundle artifact upload --- .github/workflows/bundle-gate.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/bundle-gate.yml b/.github/workflows/bundle-gate.yml index 422a525..51d00db 100644 --- a/.github/workflows/bundle-gate.yml +++ b/.github/workflows/bundle-gate.yml @@ -45,14 +45,6 @@ jobs: - name: Build a single-file stable plugin from source run: npm run build:plugin - - name: Upload generated stable plugin - if: matrix.os == 'ubuntu-latest' - uses: actions/upload-artifact@v4 - with: - name: stable-plugin-source-build - path: src/index.js - if-no-files-found: error - - name: Verify generated entry syntax and import run: | node --check src/index.js From 1129125b5fddca055bd0790d79e0d20fffecee11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:58:00 +0300 Subject: [PATCH 09/25] ci: add one-shot loop-now diagnostics patch --- .../workflows/patch-loop-now-diagnostics.yml | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/patch-loop-now-diagnostics.yml diff --git a/.github/workflows/patch-loop-now-diagnostics.yml b/.github/workflows/patch-loop-now-diagnostics.yml new file mode 100644 index 0000000..81cbf32 --- /dev/null +++ b/.github/workflows/patch-loop-now-diagnostics.yml @@ -0,0 +1,49 @@ +name: Patch loop-now canary diagnostics + +on: + push: + branches: + - test/stable-loop-now-real-host + paths: + - ".github/workflows/patch-loop-now-diagnostics.yml" + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: test/stable-loop-now-real-host + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Patch diagnostics and remove helper + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs') + const file = 'scripts/host-loop-canary.mjs' + let text = fs.readFileSync(file, 'utf8') + const oldWait = ' throw new Error(`timed out waiting for ${description}\\n${diagnostics()}`)' + const newWait = ' const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics\n throw new Error(`timed out waiting for ${description}\\n${detail}`)' + if (!text.includes(oldWait)) throw new Error('waitFor diagnostic marker not found') + text = text.replace(oldWait, newWait) + const oldDiag = ' const diagnostics = async () => {\n let state = "missing"\n try { state = await readFile(stateFile, "utf8") } catch {}\n return `provider=${JSON.stringify(provider.stats)}\\ncommandError=${String(commandError ?? "none")}\\nstate=${state}\\nserver log:\\n${serverLog}`\n }' + const newDiag = ' const diagnostics = async () => {\n let state = "missing"\n let loopLog = "missing"\n try { state = await readFile(stateFile, "utf8") } catch {}\n try { loopLog = await readFile(path.join(workspace, ".opencode", "opencode-loop", "loop.log"), "utf8") } catch {}\n return `provider=${JSON.stringify(provider.stats)}\\ncommandError=${String(commandError ?? "none")}\\nstate=${state}\\nloop log:\\n${loopLog}\\nserver log:\\n${serverLog}`\n }' + if (!text.includes(oldDiag)) throw new Error('diagnostics block marker not found') + text = text.replace(oldDiag, newDiag) + const oldRunNow = ' await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", () => `provider=${JSON.stringify(provider.stats)}\\nserver log:\\n${serverLog}`)' + const newRunNow = ' await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", diagnostics)' + if (!text.includes(oldRunNow)) throw new Error('run-now wait marker not found') + text = text.replace(oldRunNow, newRunNow) + fs.writeFileSync(file, text) + NODE + rm .github/workflows/patch-loop-now-diagnostics.yml + git config user.email "opencode-loop-ci@example.invalid" + git config user.name "OpenCode Loop CI" + git add scripts/host-loop-canary.mjs .github/workflows/patch-loop-now-diagnostics.yml + git commit -m "test: add loop-now timeout state diagnostics" + git push origin HEAD:test/stable-loop-now-real-host From 007f26de41310ffebe6c98503effc49fc196ccc4 Mon Sep 17 00:00:00 2001 From: OpenCode Loop CI Date: Tue, 18 Aug 2026 00:58:13 +0000 Subject: [PATCH 10/25] test: add loop-now timeout state diagnostics --- .../workflows/patch-loop-now-diagnostics.yml | 49 ------------------- scripts/host-loop-canary.mjs | 9 ++-- 2 files changed, 6 insertions(+), 52 deletions(-) delete mode 100644 .github/workflows/patch-loop-now-diagnostics.yml diff --git a/.github/workflows/patch-loop-now-diagnostics.yml b/.github/workflows/patch-loop-now-diagnostics.yml deleted file mode 100644 index 81cbf32..0000000 --- a/.github/workflows/patch-loop-now-diagnostics.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Patch loop-now canary diagnostics - -on: - push: - branches: - - test/stable-loop-now-real-host - paths: - - ".github/workflows/patch-loop-now-diagnostics.yml" - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: test/stable-loop-now-real-host - - uses: actions/setup-node@v6 - with: - node-version: "24" - - name: Patch diagnostics and remove helper - shell: bash - run: | - node <<'NODE' - const fs = require('node:fs') - const file = 'scripts/host-loop-canary.mjs' - let text = fs.readFileSync(file, 'utf8') - const oldWait = ' throw new Error(`timed out waiting for ${description}\\n${diagnostics()}`)' - const newWait = ' const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics\n throw new Error(`timed out waiting for ${description}\\n${detail}`)' - if (!text.includes(oldWait)) throw new Error('waitFor diagnostic marker not found') - text = text.replace(oldWait, newWait) - const oldDiag = ' const diagnostics = async () => {\n let state = "missing"\n try { state = await readFile(stateFile, "utf8") } catch {}\n return `provider=${JSON.stringify(provider.stats)}\\ncommandError=${String(commandError ?? "none")}\\nstate=${state}\\nserver log:\\n${serverLog}`\n }' - const newDiag = ' const diagnostics = async () => {\n let state = "missing"\n let loopLog = "missing"\n try { state = await readFile(stateFile, "utf8") } catch {}\n try { loopLog = await readFile(path.join(workspace, ".opencode", "opencode-loop", "loop.log"), "utf8") } catch {}\n return `provider=${JSON.stringify(provider.stats)}\\ncommandError=${String(commandError ?? "none")}\\nstate=${state}\\nloop log:\\n${loopLog}\\nserver log:\\n${serverLog}`\n }' - if (!text.includes(oldDiag)) throw new Error('diagnostics block marker not found') - text = text.replace(oldDiag, newDiag) - const oldRunNow = ' await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", () => `provider=${JSON.stringify(provider.stats)}\\nserver log:\\n${serverLog}`)' - const newRunNow = ' await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", diagnostics)' - if (!text.includes(oldRunNow)) throw new Error('run-now wait marker not found') - text = text.replace(oldRunNow, newRunNow) - fs.writeFileSync(file, text) - NODE - rm .github/workflows/patch-loop-now-diagnostics.yml - git config user.email "opencode-loop-ci@example.invalid" - git config user.name "OpenCode Loop CI" - git add scripts/host-loop-canary.mjs .github/workflows/patch-loop-now-diagnostics.yml - git commit -m "test: add loop-now timeout state diagnostics" - git push origin HEAD:test/stable-loop-now-real-host diff --git a/scripts/host-loop-canary.mjs b/scripts/host-loop-canary.mjs index e625a28..8c28416 100644 --- a/scripts/host-loop-canary.mjs +++ b/scripts/host-loop-canary.mjs @@ -226,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) { @@ -339,8 +340,10 @@ 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}`) @@ -370,7 +373,7 @@ async function main() { provider.stats.runNowSequence.length = 0 await sendCommand("loop-now", "target") - await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`) + 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()}`) From d5a005b8ac2f89de44e18ae92055d55b6c6f7cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:58:19 +0300 Subject: [PATCH 11/25] ci: trigger loop-now diagnostics patch --- .../workflows/patch-loop-now-diagnostics.yml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/patch-loop-now-diagnostics.yml diff --git a/.github/workflows/patch-loop-now-diagnostics.yml b/.github/workflows/patch-loop-now-diagnostics.yml new file mode 100644 index 0000000..964916e --- /dev/null +++ b/.github/workflows/patch-loop-now-diagnostics.yml @@ -0,0 +1,50 @@ +name: Patch loop-now canary diagnostics + +# Second push triggers the one-shot helper after the workflow exists on the branch. +on: + push: + branches: + - test/stable-loop-now-real-host + paths: + - ".github/workflows/patch-loop-now-diagnostics.yml" + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: test/stable-loop-now-real-host + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Patch diagnostics and remove helper + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs') + const file = 'scripts/host-loop-canary.mjs' + let text = fs.readFileSync(file, 'utf8') + const oldWait = ' throw new Error(`timed out waiting for ${description}\\n${diagnostics()}`)' + const newWait = ' const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics\n throw new Error(`timed out waiting for ${description}\\n${detail}`)' + if (!text.includes(oldWait)) throw new Error('waitFor diagnostic marker not found') + text = text.replace(oldWait, newWait) + const oldDiag = ' const diagnostics = async () => {\n let state = "missing"\n try { state = await readFile(stateFile, "utf8") } catch {}\n return `provider=${JSON.stringify(provider.stats)}\\ncommandError=${String(commandError ?? "none")}\\nstate=${state}\\nserver log:\\n${serverLog}`\n }' + const newDiag = ' const diagnostics = async () => {\n let state = "missing"\n let loopLog = "missing"\n try { state = await readFile(stateFile, "utf8") } catch {}\n try { loopLog = await readFile(path.join(workspace, ".opencode", "opencode-loop", "loop.log"), "utf8") } catch {}\n return `provider=${JSON.stringify(provider.stats)}\\ncommandError=${String(commandError ?? "none")}\\nstate=${state}\\nloop log:\\n${loopLog}\\nserver log:\\n${serverLog}`\n }' + if (!text.includes(oldDiag)) throw new Error('diagnostics block marker not found') + text = text.replace(oldDiag, newDiag) + const oldRunNow = ' await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", () => `provider=${JSON.stringify(provider.stats)}\\nserver log:\\n${serverLog}`)' + const newRunNow = ' await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", diagnostics)' + if (!text.includes(oldRunNow)) throw new Error('run-now wait marker not found') + text = text.replace(oldRunNow, newRunNow) + fs.writeFileSync(file, text) + NODE + rm .github/workflows/patch-loop-now-diagnostics.yml + git config user.email "opencode-loop-ci@example.invalid" + git config user.name "OpenCode Loop CI" + git add scripts/host-loop-canary.mjs .github/workflows/patch-loop-now-diagnostics.yml + git commit -m "test: add loop-now timeout state diagnostics" + git push origin HEAD:test/stable-loop-now-real-host From 4485a87b29a211e7d47135c1b6456477af11957b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:59:12 +0300 Subject: [PATCH 12/25] ci: retrigger loop-now diagnostics patch --- .github/workflows/patch-loop-now-diagnostics.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/patch-loop-now-diagnostics.yml b/.github/workflows/patch-loop-now-diagnostics.yml index 964916e..68fbc70 100644 --- a/.github/workflows/patch-loop-now-diagnostics.yml +++ b/.github/workflows/patch-loop-now-diagnostics.yml @@ -1,6 +1,6 @@ name: Patch loop-now canary diagnostics -# Second push triggers the one-shot helper after the workflow exists on the branch. +# Retrigger the one-shot helper after the workflow is present on the branch. on: push: branches: From d087ea875c295ac6f43e59369ed80898857167f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 03:59:46 +0300 Subject: [PATCH 13/25] ci: remove unused diagnostics helper --- .../workflows/patch-loop-now-diagnostics.yml | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 .github/workflows/patch-loop-now-diagnostics.yml diff --git a/.github/workflows/patch-loop-now-diagnostics.yml b/.github/workflows/patch-loop-now-diagnostics.yml deleted file mode 100644 index 68fbc70..0000000 --- a/.github/workflows/patch-loop-now-diagnostics.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Patch loop-now canary diagnostics - -# Retrigger the one-shot helper after the workflow is present on the branch. -on: - push: - branches: - - test/stable-loop-now-real-host - paths: - - ".github/workflows/patch-loop-now-diagnostics.yml" - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: test/stable-loop-now-real-host - - uses: actions/setup-node@v6 - with: - node-version: "24" - - name: Patch diagnostics and remove helper - shell: bash - run: | - node <<'NODE' - const fs = require('node:fs') - const file = 'scripts/host-loop-canary.mjs' - let text = fs.readFileSync(file, 'utf8') - const oldWait = ' throw new Error(`timed out waiting for ${description}\\n${diagnostics()}`)' - const newWait = ' const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics\n throw new Error(`timed out waiting for ${description}\\n${detail}`)' - if (!text.includes(oldWait)) throw new Error('waitFor diagnostic marker not found') - text = text.replace(oldWait, newWait) - const oldDiag = ' const diagnostics = async () => {\n let state = "missing"\n try { state = await readFile(stateFile, "utf8") } catch {}\n return `provider=${JSON.stringify(provider.stats)}\\ncommandError=${String(commandError ?? "none")}\\nstate=${state}\\nserver log:\\n${serverLog}`\n }' - const newDiag = ' const diagnostics = async () => {\n let state = "missing"\n let loopLog = "missing"\n try { state = await readFile(stateFile, "utf8") } catch {}\n try { loopLog = await readFile(path.join(workspace, ".opencode", "opencode-loop", "loop.log"), "utf8") } catch {}\n return `provider=${JSON.stringify(provider.stats)}\\ncommandError=${String(commandError ?? "none")}\\nstate=${state}\\nloop log:\\n${loopLog}\\nserver log:\\n${serverLog}`\n }' - if (!text.includes(oldDiag)) throw new Error('diagnostics block marker not found') - text = text.replace(oldDiag, newDiag) - const oldRunNow = ' await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", () => `provider=${JSON.stringify(provider.stats)}\\nserver log:\\n${serverLog}`)' - const newRunNow = ' await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", diagnostics)' - if (!text.includes(oldRunNow)) throw new Error('run-now wait marker not found') - text = text.replace(oldRunNow, newRunNow) - fs.writeFileSync(file, text) - NODE - rm .github/workflows/patch-loop-now-diagnostics.yml - git config user.email "opencode-loop-ci@example.invalid" - git config user.name "OpenCode Loop CI" - git add scripts/host-loop-canary.mjs .github/workflows/patch-loop-now-diagnostics.yml - git commit -m "test: add loop-now timeout state diagnostics" - git push origin HEAD:test/stable-loop-now-real-host From 8536626f5555f0528d3cd3628993adf25461e615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:04:42 +0300 Subject: [PATCH 14/25] fix: defer stable loop-now dispatch to idle-safe scheduler --- src/source/opencode/loop-commands.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) { From 99e730e5436b060c11b90193387810b92bc9fb8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:05:09 +0300 Subject: [PATCH 15/25] test: require idle-safe stable loop-now scheduling --- scripts/loop-command-handlers-test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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") } From 2d05792019495d5b2c4eff6fe63222024fe1bfb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:06:55 +0300 Subject: [PATCH 16/25] ci: add one-shot generated bundle sync --- .github/workflows/sync-generated-bundle.yml | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/sync-generated-bundle.yml diff --git a/.github/workflows/sync-generated-bundle.yml b/.github/workflows/sync-generated-bundle.yml new file mode 100644 index 0000000..592a300 --- /dev/null +++ b/.github/workflows/sync-generated-bundle.yml @@ -0,0 +1,40 @@ +name: Sync generated stable bundle + +on: + push: + branches: + - test/stable-loop-now-real-host + paths: + - ".github/workflows/sync-generated-bundle.yml" + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: test/stable-loop-now-real-host + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm ci + - run: npm run build:plugin + - name: Commit generated stable plugin and remove helper + shell: bash + run: | + rm .github/workflows/sync-generated-bundle.yml + git config user.email "opencode-loop-ci@example.invalid" + git config user.name "OpenCode Loop CI" + git add src/index.js .github/workflows/sync-generated-bundle.yml + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "build: sync generated stable plugin" + git push origin HEAD:test/stable-loop-now-real-host From 5ee1748079ef990ffe08ed45264e4a3a3d406a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:07:10 +0300 Subject: [PATCH 17/25] ci: trigger generated bundle sync --- .github/workflows/sync-generated-bundle.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sync-generated-bundle.yml b/.github/workflows/sync-generated-bundle.yml index 592a300..72d6d3a 100644 --- a/.github/workflows/sync-generated-bundle.yml +++ b/.github/workflows/sync-generated-bundle.yml @@ -1,5 +1,6 @@ name: Sync generated stable bundle +# Second push intentionally triggers this one-shot helper after it exists on the branch. on: push: branches: From 1417f9b8a5c9328426355d88bb7b556bfa553c23 Mon Sep 17 00:00:00 2001 From: OpenCode Loop CI Date: Tue, 18 Aug 2026 01:07:26 +0000 Subject: [PATCH 18/25] build: sync generated stable plugin --- .github/workflows/sync-generated-bundle.yml | 41 --------------------- src/index.js | 2 +- 2 files changed, 1 insertion(+), 42 deletions(-) delete mode 100644 .github/workflows/sync-generated-bundle.yml diff --git a/.github/workflows/sync-generated-bundle.yml b/.github/workflows/sync-generated-bundle.yml deleted file mode 100644 index 72d6d3a..0000000 --- a/.github/workflows/sync-generated-bundle.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Sync generated stable bundle - -# Second push intentionally triggers this one-shot helper after it exists on the branch. -on: - push: - branches: - - test/stable-loop-now-real-host - paths: - - ".github/workflows/sync-generated-bundle.yml" - -permissions: - contents: write - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: test/stable-loop-now-real-host - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: npm - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: npm ci - - run: npm run build:plugin - - name: Commit generated stable plugin and remove helper - shell: bash - run: | - rm .github/workflows/sync-generated-bundle.yml - git config user.email "opencode-loop-ci@example.invalid" - git config user.name "OpenCode Loop CI" - git add src/index.js .github/workflows/sync-generated-bundle.yml - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "build: sync generated stable plugin" - git push origin HEAD:test/stable-loop-now-real-host diff --git a/src/index.js b/src/index.js index 0a3f7da..29f7163 100644 --- a/src/index.js +++ b/src/index.js @@ -1751,7 +1751,7 @@ function createLoopCommandHandlers(options = {}) { await writeState2(directory, sessionID, state); await toast2(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning"); if (count) - await maybeRunDueJobs(directory, client, sessionID); + await scheduleDueWork(directory, client, sessionID); } async function doctorLoop(directory, client, sessionID) { const state = await readState2(directory, sessionID); From 574b5ee4b6cecf63e9bb6538cfdb68269e98cb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:11:33 +0300 Subject: [PATCH 19/25] ci: add one-shot comprehensive run-now timing patch --- .../workflows/patch-comprehensive-run-now.yml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/patch-comprehensive-run-now.yml diff --git a/.github/workflows/patch-comprehensive-run-now.yml b/.github/workflows/patch-comprehensive-run-now.yml new file mode 100644 index 0000000..f5d5004 --- /dev/null +++ b/.github/workflows/patch-comprehensive-run-now.yml @@ -0,0 +1,41 @@ +name: Patch comprehensive run-now timing + +on: + push: + branches: + - test/stable-loop-now-real-host + paths: + - ".github/workflows/patch-comprehensive-run-now.yml" + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: test/stable-loop-now-real-host + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Patch comprehensive harness and remove helper + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs') + const file = 'scripts/comprehensive-test.mjs' + let text = fs.readFileSync(file, 'utf8') + const before = ` async command(command, argumentsText = "", output = { parts: [] }) {\n await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)\n return output\n },` + const after = ` async command(command, argumentsText = "", output = { parts: [] }) {\n await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)\n // /loop-now persists a request and dispatches through the scheduler's 250ms idle-safe timer.\n if (command === "loop-now") await delay(400)\n return output\n },` + if (!text.includes(before)) throw new Error('comprehensive command harness marker not found') + text = text.replace(before, after) + fs.writeFileSync(file, text) + NODE + rm .github/workflows/patch-comprehensive-run-now.yml + git config user.email "opencode-loop-ci@example.invalid" + git config user.name "OpenCode Loop CI" + git add scripts/comprehensive-test.mjs .github/workflows/patch-comprehensive-run-now.yml + git commit -m "test: wait for idle-safe loop-now scheduling" + git push origin HEAD:test/stable-loop-now-real-host From 816d74f35281deed0648f6af3d024f32f7af0d50 Mon Sep 17 00:00:00 2001 From: OpenCode Loop CI Date: Tue, 18 Aug 2026 01:11:42 +0000 Subject: [PATCH 20/25] test: wait for idle-safe loop-now scheduling --- .../workflows/patch-comprehensive-run-now.yml | 41 ------------------- scripts/comprehensive-test.mjs | 2 + 2 files changed, 2 insertions(+), 41 deletions(-) delete mode 100644 .github/workflows/patch-comprehensive-run-now.yml diff --git a/.github/workflows/patch-comprehensive-run-now.yml b/.github/workflows/patch-comprehensive-run-now.yml deleted file mode 100644 index f5d5004..0000000 --- a/.github/workflows/patch-comprehensive-run-now.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Patch comprehensive run-now timing - -on: - push: - branches: - - test/stable-loop-now-real-host - paths: - - ".github/workflows/patch-comprehensive-run-now.yml" - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: test/stable-loop-now-real-host - - uses: actions/setup-node@v6 - with: - node-version: "24" - - name: Patch comprehensive harness and remove helper - shell: bash - run: | - node <<'NODE' - const fs = require('node:fs') - const file = 'scripts/comprehensive-test.mjs' - let text = fs.readFileSync(file, 'utf8') - const before = ` async command(command, argumentsText = "", output = { parts: [] }) {\n await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)\n return output\n },` - const after = ` async command(command, argumentsText = "", output = { parts: [] }) {\n await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)\n // /loop-now persists a request and dispatches through the scheduler's 250ms idle-safe timer.\n if (command === "loop-now") await delay(400)\n return output\n },` - if (!text.includes(before)) throw new Error('comprehensive command harness marker not found') - text = text.replace(before, after) - fs.writeFileSync(file, text) - NODE - rm .github/workflows/patch-comprehensive-run-now.yml - git config user.email "opencode-loop-ci@example.invalid" - git config user.name "OpenCode Loop CI" - git add scripts/comprehensive-test.mjs .github/workflows/patch-comprehensive-run-now.yml - git commit -m "test: wait for idle-safe loop-now scheduling" - git push origin HEAD:test/stable-loop-now-real-host diff --git a/scripts/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index 4e95125..9db9e52 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()}`) { From ee98f8feb883fbf194a48f921282223349ad12a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:11:54 +0300 Subject: [PATCH 21/25] ci: trigger comprehensive run-now timing patch --- .../workflows/patch-comprehensive-run-now.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/patch-comprehensive-run-now.yml diff --git a/.github/workflows/patch-comprehensive-run-now.yml b/.github/workflows/patch-comprehensive-run-now.yml new file mode 100644 index 0000000..9222b41 --- /dev/null +++ b/.github/workflows/patch-comprehensive-run-now.yml @@ -0,0 +1,42 @@ +name: Patch comprehensive run-now timing + +# Second push triggers this one-shot helper after the workflow exists on the branch. +on: + push: + branches: + - test/stable-loop-now-real-host + paths: + - ".github/workflows/patch-comprehensive-run-now.yml" + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: test/stable-loop-now-real-host + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Patch comprehensive harness and remove helper + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs') + const file = 'scripts/comprehensive-test.mjs' + let text = fs.readFileSync(file, 'utf8') + const before = ` async command(command, argumentsText = "", output = { parts: [] }) {\n await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)\n return output\n },` + const after = ` async command(command, argumentsText = "", output = { parts: [] }) {\n await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)\n // /loop-now persists a request and dispatches through the scheduler's 250ms idle-safe timer.\n if (command === "loop-now") await delay(400)\n return output\n },` + if (!text.includes(before)) throw new Error('comprehensive command harness marker not found') + text = text.replace(before, after) + fs.writeFileSync(file, text) + NODE + rm .github/workflows/patch-comprehensive-run-now.yml + git config user.email "opencode-loop-ci@example.invalid" + git config user.name "OpenCode Loop CI" + git add scripts/comprehensive-test.mjs .github/workflows/patch-comprehensive-run-now.yml + git commit -m "test: wait for idle-safe loop-now scheduling" + git push origin HEAD:test/stable-loop-now-real-host From a9b8b3c7db0faea90fd27522c26eab922c913c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:12:41 +0300 Subject: [PATCH 22/25] ci: retrigger comprehensive run-now timing patch --- .github/workflows/patch-comprehensive-run-now.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/patch-comprehensive-run-now.yml b/.github/workflows/patch-comprehensive-run-now.yml index 9222b41..539b3cd 100644 --- a/.github/workflows/patch-comprehensive-run-now.yml +++ b/.github/workflows/patch-comprehensive-run-now.yml @@ -1,6 +1,6 @@ name: Patch comprehensive run-now timing -# Second push triggers this one-shot helper after the workflow exists on the branch. +# Retrigger the one-shot helper after the workflow is present on the branch. on: push: branches: From 528aba2f9282a8139ba71d7df6707d5866486d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:14:17 +0300 Subject: [PATCH 23/25] ci: remove temporary comprehensive patch helper --- .../workflows/patch-comprehensive-run-now.yml | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/patch-comprehensive-run-now.yml diff --git a/.github/workflows/patch-comprehensive-run-now.yml b/.github/workflows/patch-comprehensive-run-now.yml deleted file mode 100644 index 539b3cd..0000000 --- a/.github/workflows/patch-comprehensive-run-now.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Patch comprehensive run-now timing - -# Retrigger the one-shot helper after the workflow is present on the branch. -on: - push: - branches: - - test/stable-loop-now-real-host - paths: - - ".github/workflows/patch-comprehensive-run-now.yml" - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: test/stable-loop-now-real-host - - uses: actions/setup-node@v6 - with: - node-version: "24" - - name: Patch comprehensive harness and remove helper - shell: bash - run: | - node <<'NODE' - const fs = require('node:fs') - const file = 'scripts/comprehensive-test.mjs' - let text = fs.readFileSync(file, 'utf8') - const before = ` async command(command, argumentsText = "", output = { parts: [] }) {\n await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)\n return output\n },` - const after = ` async command(command, argumentsText = "", output = { parts: [] }) {\n await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)\n // /loop-now persists a request and dispatches through the scheduler's 250ms idle-safe timer.\n if (command === "loop-now") await delay(400)\n return output\n },` - if (!text.includes(before)) throw new Error('comprehensive command harness marker not found') - text = text.replace(before, after) - fs.writeFileSync(file, text) - NODE - rm .github/workflows/patch-comprehensive-run-now.yml - git config user.email "opencode-loop-ci@example.invalid" - git config user.name "OpenCode Loop CI" - git add scripts/comprehensive-test.mjs .github/workflows/patch-comprehensive-run-now.yml - git commit -m "test: wait for idle-safe loop-now scheduling" - git push origin HEAD:test/stable-loop-now-real-host From 5462280edda5e7be35413661d0c1cf9fc53379b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Tue, 18 Aug 2026 04:26:23 +0300 Subject: [PATCH 24/25] chore: add one-shot stale-busy test patch --- .github/workflows/patch-stale-busy-test.yml | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/patch-stale-busy-test.yml diff --git a/.github/workflows/patch-stale-busy-test.yml b/.github/workflows/patch-stale-busy-test.yml new file mode 100644 index 0000000..3d50678 --- /dev/null +++ b/.github/workflows/patch-stale-busy-test.yml @@ -0,0 +1,39 @@ +name: Patch stale busy test timing + +on: + push: + branches: + - test/stable-loop-now-real-host + paths: + - ".github/workflows/patch-stale-busy-test.yml" + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: test/stable-loop-now-real-host + - name: Patch comprehensive stale-busy timing + run: | + python - <<'PY' + from pathlib import Path + p = Path('scripts/comprehensive-test.mjs') + s = p.read_text() + old = ''' h.messageHistory.splice(0, h.messageHistory.length,\n { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },\n { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },\n )\n await h.command("loop-now", "stale-complete")\n''' + new = ''' h.messageHistory.splice(0, h.messageHistory.length,\n { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },\n { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },\n )\n // Let the short busy-status cache expire so the scheduler re-reads live host state\n // and can reconcile the completed assistant tail instead of trusting cached busy.\n await delay(1_700)\n await h.command("loop-now", "stale-complete")\n''' + if old not in s: + raise SystemExit('target stale-complete block not found') + p.write_text(s.replace(old, new, 1)) + PY + - name: Commit patch and remove helper + run: | + rm .github/workflows/patch-stale-busy-test.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add scripts/comprehensive-test.mjs .github/workflows/patch-stale-busy-test.yml + git commit -m "test: align stale-busy recovery with scheduler cache" + git push origin HEAD:test/stable-loop-now-real-host From a7fee4f7567d98a4d53212233d1985f634dc9c0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:26:31 +0000 Subject: [PATCH 25/25] test: align stale-busy recovery with scheduler cache --- .github/workflows/patch-stale-busy-test.yml | 39 --------------------- scripts/comprehensive-test.mjs | 3 ++ 2 files changed, 3 insertions(+), 39 deletions(-) delete mode 100644 .github/workflows/patch-stale-busy-test.yml diff --git a/.github/workflows/patch-stale-busy-test.yml b/.github/workflows/patch-stale-busy-test.yml deleted file mode 100644 index 3d50678..0000000 --- a/.github/workflows/patch-stale-busy-test.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Patch stale busy test timing - -on: - push: - branches: - - test/stable-loop-now-real-host - paths: - - ".github/workflows/patch-stale-busy-test.yml" - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: test/stable-loop-now-real-host - - name: Patch comprehensive stale-busy timing - run: | - python - <<'PY' - from pathlib import Path - p = Path('scripts/comprehensive-test.mjs') - s = p.read_text() - old = ''' h.messageHistory.splice(0, h.messageHistory.length,\n { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },\n { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },\n )\n await h.command("loop-now", "stale-complete")\n''' - new = ''' h.messageHistory.splice(0, h.messageHistory.length,\n { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },\n { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },\n )\n // Let the short busy-status cache expire so the scheduler re-reads live host state\n // and can reconcile the completed assistant tail instead of trusting cached busy.\n await delay(1_700)\n await h.command("loop-now", "stale-complete")\n''' - if old not in s: - raise SystemExit('target stale-complete block not found') - p.write_text(s.replace(old, new, 1)) - PY - - name: Commit patch and remove helper - run: | - rm .github/workflows/patch-stale-busy-test.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add scripts/comprehensive-test.mjs .github/workflows/patch-stale-busy-test.yml - git commit -m "test: align stale-busy recovery with scheduler cache" - git push origin HEAD:test/stable-loop-now-real-host diff --git a/scripts/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index 9db9e52..629b8f3 100644 --- a/scripts/comprehensive-test.mjs +++ b/scripts/comprehensive-test.mjs @@ -479,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")