From 2faa0872c48acaac325a4ea60da5d4780959b9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 21 Aug 2026 09:11:24 +0300 Subject: [PATCH 1/5] refactor: add run admission runtime --- src/source/runtime/run-admission.js | 132 ++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/source/runtime/run-admission.js diff --git a/src/source/runtime/run-admission.js b/src/source/runtime/run-admission.js new file mode 100644 index 00000000..84f61ff4 --- /dev/null +++ b/src/source/runtime/run-admission.js @@ -0,0 +1,132 @@ +import path from "node:path" +import { now as defaultNow } from "../core/args.js" +import { isGoalJob } from "../core/jobs.js" +import { pathExists as defaultPathExists, writeState as defaultWriteState } from "../core/state.js" +import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js" +import { toast as defaultToast } from "../opencode/host.js" +import { dangerousShell as defaultDangerousShell } from "./job-workspace.js" + +function requireFunction(value, label) { + if (typeof value !== "function") throw new TypeError(`createRunAdmissionRuntime requires ${label}`) + return value +} + +export function createRunAdmissionRuntime(options = {}) { + const untilReached = requireFunction(options.untilReached, "untilReached") + const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork") + + const now = typeof options.now === "function" ? options.now : defaultNow + const pathExists = typeof options.pathExists === "function" ? options.pathExists : defaultPathExists + const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState + const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog + const runShellCommand = typeof options.runShellCommand === "function" ? options.runShellCommand : defaultRunShellCommand + const notifyJob = typeof options.notifyJob === "function" ? options.notifyJob : defaultNotifyJob + const toast = typeof options.toast === "function" ? options.toast : defaultToast + const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell + + function dueJobs(state, force = false) { + const current = now() + const due = (state.jobs || []).filter((job) => { + if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false + if (!job.enabled || job.paused) return false + if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) 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)) + } + + async function reschedule(directory, client, sessionID) { + await scheduleDueWork(directory, client, sessionID) + } + + async function stopAndRemove(directory, client, sessionID, state, job, reason, message, logEvent) { + state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id) + await writeState(directory, sessionID, state) + await notifyJob(directory, job, reason) + await toast(client, message, "success") + if (logEvent) await appendLoopLog(directory, logEvent, { sessionID, job: job.name || job.id }) + await reschedule(directory, client, sessionID) + return { admitted: false, reason } + } + + async function admitJob(directory, client, sessionID, state, job) { + const runNowRequested = Number(job.runNowRequestedAt || 0) > 0 + + if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) { + return await stopAndRemove( + directory, + client, + sessionID, + state, + job, + "max_runtime_reached", + `Loop stopped by --max-runtime: ${job.name || job.id}`, + "max-runtime", + ) + } + + if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) { + return await stopAndRemove( + directory, + client, + sessionID, + state, + job, + "stop_file", + "Loop stopped by --stop-file: " + job.stopFile, + ) + } + + if (await untilReached(directory, job)) { + return await stopAndRemove( + directory, + client, + sessionID, + state, + job, + "until_reached", + `Loop stopped by --until: ${job.until}`, + ) + } + + if (job.preflightCommand) { + if (job.safe && dangerousShell(job.preflightCommand)) { + if (runNowRequested) delete job.runNowRequestedAt + job.paused = true + await writeState(directory, sessionID, state) + await notifyJob(directory, job, "preflight_blocked") + await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error") + await reschedule(directory, client, sessionID) + return { admitted: false, reason: "preflight_blocked" } + } + + const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000) + await appendLoopLog(directory, "preflight", { + sessionID, + job: job.name || job.id, + command: job.preflightCommand, + 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 + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000) + state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate) + await writeState(directory, sessionID, state) + await notifyJob(directory, job, "preflight_failed") + await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning") + await reschedule(directory, client, sessionID) + return { admitted: false, reason: "preflight_failed" } + } + } + + return { admitted: true, job, runNowRequested } + } + + return { dueJobs, admitJob } +} From 3c41b80507273f533c8eed511dbff4fd976d9900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 21 Aug 2026 09:11:42 +0300 Subject: [PATCH 2/5] test: cover run admission runtime --- scripts/run-admission-test.mjs | 131 +++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 scripts/run-admission-test.mjs diff --git a/scripts/run-admission-test.mjs b/scripts/run-admission-test.mjs new file mode 100644 index 00000000..8adc4fe9 --- /dev/null +++ b/scripts/run-admission-test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict" +import { createRunAdmissionRuntime } from "../src/source/runtime/run-admission.js" + +assert.throws(() => createRunAdmissionRuntime({}), /untilReached/) +assert.throws(() => createRunAdmissionRuntime({ untilReached: async () => false }), /scheduleDueWork/) + +let clock = 1_000_000 +const writes = [] +const logs = [] +const shellCalls = [] +const notifications = [] +const toasts = [] +const schedules = [] +const stopFiles = new Set() +const untilJobs = new Set() + +const runtime = createRunAdmissionRuntime({ + untilReached: async (_directory, job) => untilJobs.has(job.id), + scheduleDueWork: async (...args) => { schedules.push(args) }, + now: () => clock, + pathExists: async (target) => stopFiles.has(String(target).replace(/\\/g, "/")), + writeState: async (...args) => { writes.push(args) }, + appendLoopLog: async (...args) => { logs.push(args) }, + runShellCommand: async (command) => { + shellCalls.push(command) + if (command.includes("fail")) return { code: 7, stdout: "bad", stderr: "preflight error" } + return { code: 0, stdout: "ok", stderr: "" } + }, + notifyJob: async (...args) => { notifications.push(args) }, + toast: async (...args) => { toasts.push(args) }, + dangerousShell: (command) => command.includes("danger"), +}) + +const dueState = { + jobs: [ + { id: "disabled", enabled: false, intervalMs: 0 }, + { id: "paused", enabled: true, paused: true, intervalMs: 0 }, + { id: "maxed", enabled: true, maxRuns: 1, runCount: 1, intervalMs: 0 }, + { id: "goal-done", kind: "goal", goalStatus: "completed", enabled: true, intervalMs: 0 }, + { id: "watch-no", enabled: true, watchPaths: ["x"], watchTriggered: false, intervalMs: 0 }, + { id: "watch-yes", enabled: true, watchPaths: ["x"], watchTriggered: true, intervalMs: 99_999 }, + { id: "interval", enabled: true, intervalMs: 10_000, lastRunAt: clock - 20_000 }, + { id: "run-now", enabled: true, intervalMs: 999_999, lastRunAt: clock, runNowRequestedAt: clock }, + ], +} +assert.deepEqual(runtime.dueJobs(dueState).map((job) => job.id), ["run-now", "watch-yes", "interval"]) +assert.deepEqual(runtime.dueJobs(dueState, true).map((job) => job.id), ["run-now", "watch-no", "watch-yes", "interval"]) + +const expired = { + id: "expired", + name: "expired-job", + enabled: true, + createdAt: new Date(clock - 20_000).toISOString(), + maxRuntimeMs: 10_000, +} +const expiredState = { jobs: [expired, { id: "keep", enabled: true }] } +const expiredResult = await runtime.admitJob("/repo", {}, "session-expired", expiredState, expired) +assert.deepEqual(expiredResult, { admitted: false, reason: "max_runtime_reached" }) +assert.deepEqual(expiredState.jobs.map((job) => job.id), ["keep"]) +assert.equal(notifications.at(-1)[2], "max_runtime_reached") +assert.equal(logs.at(-1)[1], "max-runtime") +assert.match(toasts.at(-1)[1], /--max-runtime/) +assert.equal(schedules.at(-1)[2], "session-expired") + +stopFiles.add("/repo/STOP") +const stopped = { id: "stopped", enabled: true, stopFile: "STOP" } +const stoppedState = { jobs: [stopped] } +const stoppedResult = await runtime.admitJob("/repo", {}, "session-stop", stoppedState, stopped) +assert.deepEqual(stoppedResult, { admitted: false, reason: "stop_file" }) +assert.equal(stoppedState.jobs.length, 0) +assert.equal(notifications.at(-1)[2], "stop_file") +assert.match(toasts.at(-1)[1], /--stop-file/) + +untilJobs.add("until-job") +const untilJob = { id: "until-job", enabled: true, until: "DONE" } +const untilState = { jobs: [untilJob] } +const untilResult = await runtime.admitJob("/repo", {}, "session-until", untilState, untilJob) +assert.deepEqual(untilResult, { admitted: false, reason: "until_reached" }) +assert.equal(untilState.jobs.length, 0) +assert.equal(notifications.at(-1)[2], "until_reached") +assert.match(toasts.at(-1)[1], /--until/) + +const blocked = { + id: "blocked-preflight", + enabled: true, + safe: true, + preflightCommand: "danger --all", + runNowRequestedAt: clock, +} +const blockedState = { jobs: [blocked] } +const shellCountBeforeBlock = shellCalls.length +const blockedResult = await runtime.admitJob("/repo", {}, "session-blocked", blockedState, blocked) +assert.deepEqual(blockedResult, { admitted: false, reason: "preflight_blocked" }) +assert.equal(shellCalls.length, shellCountBeforeBlock) +assert.equal(blocked.paused, true) +assert.equal(blocked.runNowRequestedAt, undefined) +assert.equal(notifications.at(-1)[2], "preflight_blocked") +assert.equal(writes.at(-1)[2], blockedState) + +const failed = { + id: "failed-preflight", + enabled: true, + preflightCommand: "preflight-fail", + runNowRequestedAt: clock, + failureCount: 2, +} +const failedState = { jobs: [failed] } +const failedResult = await runtime.admitJob("/repo", {}, "session-failed", failedState, failed) +assert.deepEqual(failedResult, { admitted: false, reason: "preflight_failed" }) +assert.equal(failed.paused, true) +assert.equal(failed.failureCount, 3) +assert.equal(failed.runNowRequestedAt, undefined) +assert.match(failed.lastPreflightFailure, /exit=7/) +assert.match(failed.lastPreflightFailure, /preflight error/) +assert.equal(logs.findLast((entry) => entry[1] === "preflight")?.[2].code, 7) +assert.equal(notifications.at(-1)[2], "preflight_failed") + +const admitted = { + id: "admitted", + enabled: true, + preflightCommand: "preflight-ok", + runNowRequestedAt: clock, +} +const admittedState = { jobs: [admitted] } +const admittedResult = await runtime.admitJob("/repo", {}, "session-admitted", admittedState, admitted) +assert.deepEqual(admittedResult, { admitted: true, job: admitted, runNowRequested: true }) +assert.equal(admitted.paused, undefined) +assert.equal(admitted.runNowRequestedAt, clock) +assert.equal(logs.findLast((entry) => entry[1] === "preflight")?.[2].code, 0) + +console.log("run admission runtime tests passed") From bfa8c326f77887a488296f0724d473944ea580f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 21 Aug 2026 09:12:10 +0300 Subject: [PATCH 3/5] test: wire run admission coverage --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bf0c84d7..32f13cd8 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool", "build:plugin:npm": "npm run build:plugin", "prepack": "node --check src/index.js", - "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", - "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", + "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", + "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs", "install:global": "node scripts/install-with-goals.mjs", "pack:zip": "node scripts/make-zip.mjs" From 3daf64b7467651d6061a15ec5cbbfcb722986dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 21 Aug 2026 09:12:59 +0300 Subject: [PATCH 4/5] refactor: compose run admission runtime --- src/source/runtime/executor.js | 93 +++++++--------------------------- 1 file changed, 19 insertions(+), 74 deletions(-) diff --git a/src/source/runtime/executor.js b/src/source/runtime/executor.js index eda5dca8..5e647aee 100644 --- a/src/source/runtime/executor.js +++ b/src/source/runtime/executor.js @@ -1,7 +1,6 @@ -import path from "node:path" import { now as defaultNow } from "../core/args.js" import { isGoalJob } from "../core/jobs.js" -import { pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js" +import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js" import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js" import { sdkErrorMessage as defaultErrorMessage } from "../opencode/sdk.js" import { fireSdk as defaultFireSdk, log as defaultLog, toast as defaultToast } from "../opencode/host.js" @@ -10,6 +9,7 @@ import { createSessionStatusRuntime } from "./session-status.js" import { createCompactionRuntime } from "./compaction.js" import { createActionDispatcher } from "./action-dispatch.js" import { createRunFinalizationRuntime } from "./run-finalization.js" +import { createRunAdmissionRuntime } from "./run-admission.js" const DEFAULT_ACTIVE_GUARD_MS = 45_000 const DEFAULT_BUSY_RETRY_MS = 5_000 @@ -37,7 +37,6 @@ export function createLoopExecutor(options = {}) { const now = typeof options.now === "function" ? options.now : defaultNow const readState = typeof options.readState === "function" ? options.readState : defaultReadState const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState - const pathExists = typeof options.pathExists === "function" ? options.pathExists : defaultPathExists const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog const runShellCommand = typeof options.runShellCommand === "function" ? options.runShellCommand : defaultRunShellCommand const notifyJob = typeof options.notifyJob === "function" ? options.notifyJob : defaultNotifyJob @@ -111,20 +110,19 @@ export function createLoopExecutor(options = {}) { dangerousShell, }) - function dueJobs(state, force = false) { - const current = now() - const due = (state.jobs || []).filter((job) => { - if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false - if (!job.enabled || job.paused) return false - if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) 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)) - } + const admissionRuntime = createRunAdmissionRuntime({ + untilReached, + scheduleDueWork, + now, + pathExists: options.pathExists, + writeState, + appendLoopLog, + runShellCommand, + notifyJob, + toast, + dangerousShell, + }) + const dueJobs = admissionRuntime.dueJobs function clearActiveRun(sessionID) { const active = activeRuns.get(sessionID) @@ -249,64 +247,11 @@ export function createLoopExecutor(options = {}) { return } job = due[0] - const runNowRequested = Number(job.runNowRequestedAt || 0) > 0 - - if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) { - state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id) - await writeState(directory, sessionID, state) - await notifyJob(directory, job, "max_runtime_reached") - await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success") - await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id }) - await reschedule() - return - } - if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) { - state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id) - await writeState(directory, sessionID, state) - await notifyJob(directory, job, "stop_file") - await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success") - await reschedule() - return - } - if (await untilReached(directory, job)) { - state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id) - await writeState(directory, sessionID, state) - await notifyJob(directory, job, "until_reached") - await toast(client, `Loop stopped by --until: ${job.until}`, "success") - await reschedule() - return - } - if (job.preflightCommand) { - if (job.safe && dangerousShell(job.preflightCommand)) { - if (runNowRequested) delete job.runNowRequestedAt - job.paused = true - await writeState(directory, sessionID, state) - await notifyJob(directory, job, "preflight_blocked") - await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error") - await reschedule() - return - } - const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000) - await appendLoopLog(directory, "preflight", { - sessionID, - job: job.name || job.id, - command: job.preflightCommand, - 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 + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000) - state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate) - await writeState(directory, sessionID, state) - await notifyJob(directory, job, "preflight_failed") - await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning") - await reschedule() - return - } - } + const admission = await admissionRuntime.admitJob(directory, client, sessionID, state, job) + if (!admission.admitted) return + job = admission.job + const runNowRequested = admission.runNowRequested job = await ensureBranch(directory, job, client, sessionID) const compactResult = await compactionRuntime.maybeCompact(directory, client, sessionID, job) From d1203ea998267731014ab4e9c0340d9911d42729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 21 Aug 2026 09:15:39 +0300 Subject: [PATCH 5/5] test: make stop-file admission portable --- scripts/run-admission-test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/run-admission-test.mjs b/scripts/run-admission-test.mjs index 8adc4fe9..f525179f 100644 --- a/scripts/run-admission-test.mjs +++ b/scripts/run-admission-test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import path from "node:path" import { createRunAdmissionRuntime } from "../src/source/runtime/run-admission.js" assert.throws(() => createRunAdmissionRuntime({}), /untilReached/) @@ -13,12 +14,13 @@ const toasts = [] const schedules = [] const stopFiles = new Set() const untilJobs = new Set() +const normalizePath = (value) => String(value).replace(/\\/g, "/") const runtime = createRunAdmissionRuntime({ untilReached: async (_directory, job) => untilJobs.has(job.id), scheduleDueWork: async (...args) => { schedules.push(args) }, now: () => clock, - pathExists: async (target) => stopFiles.has(String(target).replace(/\\/g, "/")), + pathExists: async (target) => stopFiles.has(normalizePath(target)), writeState: async (...args) => { writes.push(args) }, appendLoopLog: async (...args) => { logs.push(args) }, runShellCommand: async (command) => { @@ -62,7 +64,7 @@ assert.equal(logs.at(-1)[1], "max-runtime") assert.match(toasts.at(-1)[1], /--max-runtime/) assert.equal(schedules.at(-1)[2], "session-expired") -stopFiles.add("/repo/STOP") +stopFiles.add(normalizePath(path.resolve("/repo", "STOP"))) const stopped = { id: "stopped", enabled: true, stopFile: "STOP" } const stoppedState = { jobs: [stopped] } const stoppedResult = await runtime.admitJob("/repo", {}, "session-stop", stoppedState, stopped)