diff --git a/scripts/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index 629b8f3..1b08697 100644 --- a/scripts/comprehensive-test.mjs +++ b/scripts/comprehensive-test.mjs @@ -358,9 +358,12 @@ async function testActionRoutingAndSafety() { await h.command("loop-clear") await h.command("loop-shell", "0s --safe rm -r -f ./important") await h.command("loop-now", "shell") - const separateFlagsState = await h.readState() + const separateFlagsState = await waitForValue(async () => { + const candidate = await h.readState() + return candidate.jobs[0]?.lastFailureReason === "safe_shell_blocked" ? candidate : undefined + }, 2_000) assert.equal(h.records.shells.length, 0) - assert.equal(separateFlagsState.jobs[0].lastFailureReason, "safe_shell_blocked", "separate rm -r -f flags must be blocked") + assert.equal(separateFlagsState?.jobs[0]?.lastFailureReason, "safe_shell_blocked", "separate rm -r -f flags must be blocked") } finally { await h.cleanup() } diff --git a/scripts/loop-command-handlers-test.mjs b/scripts/loop-command-handlers-test.mjs index fd81901..e745618 100644 --- a/scripts/loop-command-handlers-test.mjs +++ b/scripts/loop-command-handlers-test.mjs @@ -153,6 +153,22 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD assert.match(text, /goal:blocked,paused,checkpoint-only,git-checkpoint/) } +{ + const sessionID = "status-no-now" + const h = harness({ + [sessionID]: { + jobs: [loopJob("delayed", { + intervalMs: 10_000, + lastRunAt: 0, + immediate: false, + createdAt: new Date(5_000).toISOString(), + })], + }, + }, { now: () => 10_000 }) + await h.handlers.statusLoop("/work", {}, sessionID) + assert.match(h.messages[0][2], /due in 5s/, "--no-now status must count the first interval from createdAt") +} + { const sessionID = "status-empty" const h = harness({ [sessionID]: { jobs: [] } }) diff --git a/src/index.js b/src/index.js index 29f7163..a0f18eb 100644 --- a/src/index.js +++ b/src/index.js @@ -1698,7 +1698,12 @@ function createLoopCommandHandlers(options = {}) { const state = await readState2(directory, sessionID); const jobs = state.jobs || []; const lines = jobs.length ? jobs.map((job, index) => { - const dueIn = Number(job.runNowRequestedAt || 0) > 0 ? 0 : Math.max(0, job.intervalMs - (now2() - (job.lastRunAt || 0))); + const current = now2(); + const intervalMs = Number(job.intervalMs || 0); + const lastRunAt = Number(job.lastRunAt || 0); + const createdAt = Date.parse(job.createdAt || ""); + const dueAt = Number(job.runNowRequestedAt || 0) > 0 ? current : lastRunAt > 0 ? lastRunAt + intervalMs : job.immediate === false && Number.isFinite(createdAt) ? createdAt + intervalMs : current; + const dueIn = Math.max(0, dueAt - current); 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."]; diff --git a/src/source/opencode/loop-commands.js b/src/source/opencode/loop-commands.js index 15d107e..04d2986 100644 --- a/src/source/opencode/loop-commands.js +++ b/src/source/opencode/loop-commands.js @@ -89,7 +89,18 @@ export function createLoopCommandHandlers(options = {}) { const state = await readState(directory, sessionID) const jobs = state.jobs || [] const lines = jobs.length ? jobs.map((job, index) => { - const dueIn = Number(job.runNowRequestedAt || 0) > 0 ? 0 : Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0))) + const current = now() + const intervalMs = Number(job.intervalMs || 0) + const lastRunAt = Number(job.lastRunAt || 0) + const createdAt = Date.parse(job.createdAt || "") + const dueAt = Number(job.runNowRequestedAt || 0) > 0 + ? current + : lastRunAt > 0 + ? lastRunAt + intervalMs + : job.immediate === false && Number.isFinite(createdAt) + ? createdAt + intervalMs + : current + const dueIn = Math.max(0, dueAt - current) 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."]