@bybrawe/opencode-loop 0.5.33 → 0.5.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +197 -283
- package/commands/loop.md +1 -1
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +1 -1
- package/scripts/host-loop-canary.mjs +8 -3
- package/scripts/loop-command-handlers-test.mjs +33 -4
- package/scripts/loop-registration-test.mjs +77 -3
- package/scripts/schedule-policy-test.mjs +114 -0
- package/scripts/schedule-syntax-test.mjs +75 -0
- package/scripts/session-status-idle-recovery-test.mjs +44 -0
- package/src/index.js +433 -100
- package/src/source/core/continuation.js +20 -0
- package/src/source/core/jobs.js +3 -0
- package/src/source/core/schedule-syntax.js +93 -0
- package/src/source/opencode/loop-commands.js +39 -30
- package/src/source/opencode/loop-registration.js +40 -3
- package/src/source/runtime/companion-goal.js +37 -0
- package/src/source/runtime/loop-diagnostics.js +45 -0
- package/src/source/runtime/run-admission.js +2 -13
- package/src/source/runtime/schedule-policy.js +80 -0
- package/src/source/runtime/scheduler-diagnostics.js +30 -0
- package/src/source/runtime/scheduler.js +10 -23
- package/src/source/runtime/session-status.js +28 -4
package/src/index.js
CHANGED
|
@@ -1021,6 +1021,27 @@ function commandArgsText(args) {
|
|
|
1021
1021
|
return String(args);
|
|
1022
1022
|
}
|
|
1023
1023
|
|
|
1024
|
+
// src/source/core/continuation.js
|
|
1025
|
+
var CONTINUATION_SHORTHANDS = new Set([
|
|
1026
|
+
"continue",
|
|
1027
|
+
"continue.",
|
|
1028
|
+
"continue working",
|
|
1029
|
+
"keep going",
|
|
1030
|
+
"go on",
|
|
1031
|
+
"devam",
|
|
1032
|
+
"devam et",
|
|
1033
|
+
"devam et.",
|
|
1034
|
+
"devam et bakal\u0131m"
|
|
1035
|
+
]);
|
|
1036
|
+
function isContinuationShorthand(value) {
|
|
1037
|
+
return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
|
|
1038
|
+
}
|
|
1039
|
+
function continuationProjectInstruction(value) {
|
|
1040
|
+
if (!isContinuationShorthand(value))
|
|
1041
|
+
return "";
|
|
1042
|
+
return "Treat this as continuation of the current project and conversation, not a fresh task. Inspect the repository state, relevant files, TODO/progress notes, recent changes, and git status as needed to identify the next unfinished step. Continue from existing work, do not redo completed work, and verify meaningful changes when practical.";
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1024
1045
|
// src/source/core/jobs.js
|
|
1025
1046
|
function presetDefaults(name) {
|
|
1026
1047
|
if (name === "loop-compact")
|
|
@@ -1086,6 +1107,9 @@ function actionKind(action, job = {}) {
|
|
|
1086
1107
|
}
|
|
1087
1108
|
function decoratePrompt(job) {
|
|
1088
1109
|
const additions = [];
|
|
1110
|
+
const continuation = continuationProjectInstruction(job.action);
|
|
1111
|
+
if (continuation)
|
|
1112
|
+
additions.push(continuation);
|
|
1089
1113
|
if (job.progressFile)
|
|
1090
1114
|
additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
|
|
1091
1115
|
if (job.lastVerifyFailure)
|
|
@@ -1623,8 +1647,185 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1623
1647
|
}
|
|
1624
1648
|
|
|
1625
1649
|
// src/source/opencode/loop-commands.js
|
|
1650
|
+
import { promises as fs6 } from "fs";
|
|
1651
|
+
import path7 from "path";
|
|
1652
|
+
|
|
1653
|
+
// src/source/runtime/companion-goal.js
|
|
1626
1654
|
import { promises as fs4 } from "fs";
|
|
1627
1655
|
import path5 from "path";
|
|
1656
|
+
function goalRoot(directory) {
|
|
1657
|
+
return path5.join(directory, ".opencode", "goals");
|
|
1658
|
+
}
|
|
1659
|
+
async function findDedicatedGoalForSession(directory, sessionID) {
|
|
1660
|
+
if (!directory || !sessionID)
|
|
1661
|
+
return;
|
|
1662
|
+
let names;
|
|
1663
|
+
try {
|
|
1664
|
+
names = await fs4.readdir(goalRoot(directory));
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
if (error?.code === "ENOENT")
|
|
1667
|
+
return;
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
for (const name of names) {
|
|
1671
|
+
if (!name.endsWith(".json"))
|
|
1672
|
+
continue;
|
|
1673
|
+
try {
|
|
1674
|
+
const value = JSON.parse(await fs4.readFile(path5.join(goalRoot(directory), name), "utf8"));
|
|
1675
|
+
if (value?.sessionID === sessionID)
|
|
1676
|
+
return value;
|
|
1677
|
+
} catch {}
|
|
1678
|
+
}
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
function dedicatedGoalOwnsContinuation(goal) {
|
|
1682
|
+
return goal?.status === "active";
|
|
1683
|
+
}
|
|
1684
|
+
function dedicatedGoalSummary(goal) {
|
|
1685
|
+
if (!goal)
|
|
1686
|
+
return "not detected";
|
|
1687
|
+
const id = String(goal.id || "unknown").slice(0, 12);
|
|
1688
|
+
const status = String(goal.status || "unknown");
|
|
1689
|
+
return `${status} (${id})`;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/source/runtime/loop-diagnostics.js
|
|
1693
|
+
import { promises as fs5 } from "fs";
|
|
1694
|
+
import path6 from "path";
|
|
1695
|
+
|
|
1696
|
+
// src/source/runtime/schedule-policy.js
|
|
1697
|
+
var TERMINAL_GOAL_STATUSES = new Set(["completed", "blocked", "cleared"]);
|
|
1698
|
+
function inferredScheduleMode(job) {
|
|
1699
|
+
const explicit = String(job?.scheduleMode || "").toLowerCase();
|
|
1700
|
+
if (["idle", "interval", "once", "watch"].includes(explicit))
|
|
1701
|
+
return explicit;
|
|
1702
|
+
if (job?.watchPaths?.length)
|
|
1703
|
+
return "watch";
|
|
1704
|
+
if (Number(job?.maxRuns || 0) === 1 && job?.immediate === false && Number(job?.intervalMs || 0) > 0)
|
|
1705
|
+
return "once";
|
|
1706
|
+
return Number(job?.intervalMs || 0) === 0 ? "idle" : "interval";
|
|
1707
|
+
}
|
|
1708
|
+
function jobRunnable(job) {
|
|
1709
|
+
if (!job)
|
|
1710
|
+
return false;
|
|
1711
|
+
if (isGoalJob(job) && TERMINAL_GOAL_STATUSES.has(job.goalStatus))
|
|
1712
|
+
return false;
|
|
1713
|
+
if (!job.enabled || job.paused)
|
|
1714
|
+
return false;
|
|
1715
|
+
if (Number(job.maxRuns || 0) > 0 && Number(job.runCount || 0) >= Number(job.maxRuns || 0))
|
|
1716
|
+
return false;
|
|
1717
|
+
return true;
|
|
1718
|
+
}
|
|
1719
|
+
function jobDueAt(job, current = Date.now()) {
|
|
1720
|
+
if (!jobRunnable(job))
|
|
1721
|
+
return Infinity;
|
|
1722
|
+
if (Number(job.runNowRequestedAt || 0) > 0)
|
|
1723
|
+
return current;
|
|
1724
|
+
const created = Date.parse(job.createdAt || "");
|
|
1725
|
+
if (Number(job.maxRuntimeMs || 0) > 0 && Number.isFinite(created) && current - created >= Number(job.maxRuntimeMs || 0))
|
|
1726
|
+
return current;
|
|
1727
|
+
if (job.watchPaths?.length)
|
|
1728
|
+
return job.watchTriggered === true ? current : Infinity;
|
|
1729
|
+
const intervalMs = Number(job.intervalMs || 0);
|
|
1730
|
+
if (intervalMs === 0)
|
|
1731
|
+
return current;
|
|
1732
|
+
const lastRunAt = Number(job.lastRunAt || 0);
|
|
1733
|
+
if (!lastRunAt) {
|
|
1734
|
+
if (job.immediate === false)
|
|
1735
|
+
return (Number.isFinite(created) ? created : current) + intervalMs;
|
|
1736
|
+
return current;
|
|
1737
|
+
}
|
|
1738
|
+
return lastRunAt + intervalMs;
|
|
1739
|
+
}
|
|
1740
|
+
function jobIsDue(job, current = Date.now(), force = false) {
|
|
1741
|
+
if (!jobRunnable(job))
|
|
1742
|
+
return false;
|
|
1743
|
+
if (force)
|
|
1744
|
+
return true;
|
|
1745
|
+
return jobDueAt(job, current) <= current;
|
|
1746
|
+
}
|
|
1747
|
+
function dueJobs(state, current = Date.now(), force = false) {
|
|
1748
|
+
return (state?.jobs || []).filter((job) => jobIsDue(job, current, force)).sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0));
|
|
1749
|
+
}
|
|
1750
|
+
function nextDueDelay(state, current = Date.now()) {
|
|
1751
|
+
let soonest = Infinity;
|
|
1752
|
+
for (const job of state?.jobs || [])
|
|
1753
|
+
soonest = Math.min(soonest, jobDueAt(job, current));
|
|
1754
|
+
if (!Number.isFinite(soonest))
|
|
1755
|
+
return Infinity;
|
|
1756
|
+
return Math.max(0, soonest - current);
|
|
1757
|
+
}
|
|
1758
|
+
function scheduleDescription(job) {
|
|
1759
|
+
const mode = inferredScheduleMode(job);
|
|
1760
|
+
const intervalMs = Number(job?.intervalMs || 0);
|
|
1761
|
+
if (mode === "idle")
|
|
1762
|
+
return "every idle";
|
|
1763
|
+
if (mode === "watch")
|
|
1764
|
+
return `on watch: ${(job.watchPaths || []).join(", ")}`;
|
|
1765
|
+
if (mode === "once")
|
|
1766
|
+
return intervalMs > 0 ? `once after ${durationToText(intervalMs)}` : "once on next idle";
|
|
1767
|
+
if (job?.immediate === false)
|
|
1768
|
+
return `every ${durationToText(intervalMs)}, first after ${durationToText(intervalMs)}`;
|
|
1769
|
+
return `every ${durationToText(intervalMs)}, starts on next idle`;
|
|
1770
|
+
}
|
|
1771
|
+
function scheduleState(job, current = Date.now()) {
|
|
1772
|
+
if (!job?.enabled)
|
|
1773
|
+
return "stopped";
|
|
1774
|
+
if (job?.paused)
|
|
1775
|
+
return "paused";
|
|
1776
|
+
if (Number(job?.runNowRequestedAt || 0) > 0)
|
|
1777
|
+
return "due now; waiting for idle";
|
|
1778
|
+
const mode = inferredScheduleMode(job);
|
|
1779
|
+
const dueAt = jobDueAt(job, current);
|
|
1780
|
+
if (!Number.isFinite(dueAt))
|
|
1781
|
+
return mode === "watch" ? "waiting for watched change" : "not scheduled";
|
|
1782
|
+
if (dueAt <= current)
|
|
1783
|
+
return mode === "idle" ? "waiting for idle" : "due; waiting for idle";
|
|
1784
|
+
return `due in ${durationToText(dueAt - current)}`;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// src/source/runtime/loop-diagnostics.js
|
|
1788
|
+
function describeJobScheduling(job, current = Date.now()) {
|
|
1789
|
+
return {
|
|
1790
|
+
schedule: scheduleDescription(job),
|
|
1791
|
+
state: scheduleState(job, current)
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
async function listPersistedLoopSessions(directory, currentSessionID) {
|
|
1795
|
+
const root = stateDir(directory);
|
|
1796
|
+
let names;
|
|
1797
|
+
try {
|
|
1798
|
+
names = await fs5.readdir(root);
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
if (error?.code === "ENOENT")
|
|
1801
|
+
return [];
|
|
1802
|
+
return [];
|
|
1803
|
+
}
|
|
1804
|
+
const sessions = [];
|
|
1805
|
+
for (const name of names) {
|
|
1806
|
+
if (!name.endsWith(".json"))
|
|
1807
|
+
continue;
|
|
1808
|
+
const sessionID = name.slice(0, -5);
|
|
1809
|
+
try {
|
|
1810
|
+
const parsed = JSON.parse(await fs5.readFile(path6.join(root, name), "utf8"));
|
|
1811
|
+
const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
1812
|
+
const enabled = jobs.filter((job) => job?.enabled !== false && !job?.paused).length;
|
|
1813
|
+
const neverRan = jobs.filter((job) => Number(job?.runCount || 0) === 0).length;
|
|
1814
|
+
sessions.push({
|
|
1815
|
+
sessionID,
|
|
1816
|
+
current: sessionID === currentSessionID,
|
|
1817
|
+
jobs: jobs.length,
|
|
1818
|
+
enabled,
|
|
1819
|
+
neverRan
|
|
1820
|
+
});
|
|
1821
|
+
} catch {
|
|
1822
|
+
sessions.push({ sessionID, current: sessionID === currentSessionID, jobs: 0, enabled: 0, neverRan: 0, corrupt: true });
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return sessions.sort((a, b) => Number(b.current) - Number(a.current) || b.enabled - a.enabled || a.sessionID.localeCompare(b.sessionID));
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// src/source/opencode/loop-commands.js
|
|
1628
1829
|
var SERVICE2 = "opencode-loop";
|
|
1629
1830
|
var DEFAULT_PROGRESS_MD = `# Progress
|
|
1630
1831
|
|
|
@@ -1671,8 +1872,10 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1671
1872
|
const removeState2 = typeof options.removeState === "function" ? options.removeState : removeState;
|
|
1672
1873
|
const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists;
|
|
1673
1874
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
1674
|
-
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) =>
|
|
1675
|
-
const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) =>
|
|
1875
|
+
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs6.readFile(...args);
|
|
1876
|
+
const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs6.writeFile(...args);
|
|
1877
|
+
const listPersistedLoopSessions2 = typeof options.listPersistedLoopSessions === "function" ? options.listPersistedLoopSessions : listPersistedLoopSessions;
|
|
1878
|
+
const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession;
|
|
1676
1879
|
const runtimeVersion = options.runtimeVersion || process.version;
|
|
1677
1880
|
const runtimePlatform = options.runtimePlatform || process.platform;
|
|
1678
1881
|
async function stopLoop(directory, client, sessionID, args) {
|
|
@@ -1704,15 +1907,11 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1704
1907
|
async function statusLoop(directory, client, sessionID) {
|
|
1705
1908
|
const state = await readState2(directory, sessionID);
|
|
1706
1909
|
const jobs = state.jobs || [];
|
|
1910
|
+
const current = now2();
|
|
1707
1911
|
const lines = jobs.length ? jobs.map((job, index) => {
|
|
1708
|
-
const
|
|
1709
|
-
const intervalMs = Number(job.intervalMs || 0);
|
|
1710
|
-
const lastRunAt = Number(job.lastRunAt || 0);
|
|
1711
|
-
const createdAt = Date.parse(job.createdAt || "");
|
|
1712
|
-
const dueAt = Number(job.runNowRequestedAt || 0) > 0 ? current : lastRunAt > 0 ? lastRunAt + intervalMs : job.immediate === false && Number.isFinite(createdAt) ? createdAt + intervalMs : current;
|
|
1713
|
-
const dueIn = Math.max(0, dueAt - current);
|
|
1912
|
+
const scheduling = describeJobScheduling(job, current);
|
|
1714
1913
|
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(",");
|
|
1715
|
-
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} |
|
|
1914
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | schedule=${scheduling.schedule} | state=${scheduling.state} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | ${flags}`;
|
|
1716
1915
|
}) : ["No active loop jobs."];
|
|
1717
1916
|
await toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
|
|
1718
1917
|
await say2(client, sessionID, `OpenCode loop status:
|
|
@@ -1722,7 +1921,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1722
1921
|
async function logsLoop(directory, client, sessionID) {
|
|
1723
1922
|
let text = "No loop log found.";
|
|
1724
1923
|
try {
|
|
1725
|
-
text = (await readFile(
|
|
1924
|
+
text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
|
|
1726
1925
|
`) || text;
|
|
1727
1926
|
} catch {}
|
|
1728
1927
|
await say2(client, sessionID, `OpenCode loop logs:
|
|
@@ -1731,18 +1930,20 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1731
1930
|
async function helpLoop(client, sessionID) {
|
|
1732
1931
|
await say2(client, sessionID, [
|
|
1733
1932
|
"OpenCode Loop help:",
|
|
1734
|
-
"/loop
|
|
1735
|
-
"/loop
|
|
1736
|
-
"/loop
|
|
1737
|
-
"/loop-
|
|
1738
|
-
"/loop
|
|
1739
|
-
"/loop-
|
|
1740
|
-
|
|
1741
|
-
"/loop-
|
|
1742
|
-
"/loop
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
"/loop
|
|
1933
|
+
"/loop continue the project auto-continue forever whenever the session becomes idle",
|
|
1934
|
+
"/loop idle continue the project explicit form of the same idle loop",
|
|
1935
|
+
"/loop every 5m continue the project recurring timer; first run after 5m, always waits for idle",
|
|
1936
|
+
"/loop after 5m continue the project one-shot delayed prompt; runs once when 5m has passed and session is idle",
|
|
1937
|
+
"/loop 5m continue the project legacy compact form: starts on next idle, then every 5m",
|
|
1938
|
+
"/loop 5m --no-now continue the project legacy recurring form with first run delayed 5m",
|
|
1939
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
1940
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
1941
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
1942
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
1943
|
+
'/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
|
|
1944
|
+
"/loop --prompt-file loop-prompt.md idle loop loading its prompt from a file",
|
|
1945
|
+
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
1946
|
+
"Prompt-producing /loop jobs are blocked while dedicated /goal owns the same session; use another session or --allow-goal-overlap only intentionally.",
|
|
1746
1947
|
"/loop-doctor | /loop-init | /loop-export"
|
|
1747
1948
|
].join(`
|
|
1748
1949
|
`));
|
|
@@ -1767,22 +1968,36 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1767
1968
|
}
|
|
1768
1969
|
async function doctorLoop(directory, client, sessionID) {
|
|
1769
1970
|
const state = await readState2(directory, sessionID);
|
|
1770
|
-
await
|
|
1971
|
+
const persisted = await listPersistedLoopSessions2(directory, sessionID);
|
|
1972
|
+
const otherSessions = persisted.filter((entry) => !entry.current && entry.enabled > 0);
|
|
1973
|
+
const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID);
|
|
1974
|
+
const lines = [
|
|
1771
1975
|
"OpenCode Loop doctor:",
|
|
1772
1976
|
`- plugin: ${SERVICE2}`,
|
|
1773
1977
|
`- project directory: ${directory}`,
|
|
1774
1978
|
`- state directory: ${stateDir(directory)}`,
|
|
1775
|
-
`-
|
|
1979
|
+
`- current session: ${sessionID}`,
|
|
1980
|
+
`- current-session jobs: ${(state.jobs || []).length}`,
|
|
1981
|
+
`- dedicated /goal: ${dedicatedGoalSummary(dedicatedGoal)}`,
|
|
1982
|
+
`- other persisted sessions with enabled jobs: ${otherSessions.length}`,
|
|
1776
1983
|
`- node: ${runtimeVersion}`,
|
|
1777
1984
|
`- platform: ${runtimePlatform}`,
|
|
1778
|
-
"- smoke test: /loop
|
|
1985
|
+
"- smoke test: /loop --max-runs 1 --dry-run continue from progress.md",
|
|
1986
|
+
"- delayed smoke test: /loop after 5m --dry-run continue from progress.md",
|
|
1987
|
+
"- recurring smoke test: /loop every 5m --dry-run continue from progress.md",
|
|
1779
1988
|
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it"
|
|
1780
|
-
]
|
|
1989
|
+
];
|
|
1990
|
+
for (const entry of otherSessions.slice(0, 8)) {
|
|
1991
|
+
lines.push(`- other session ${entry.sessionID}: jobs=${entry.jobs}, enabled=${entry.enabled}, never-ran=${entry.neverRan}`);
|
|
1992
|
+
}
|
|
1993
|
+
if (otherSessions.length > 8)
|
|
1994
|
+
lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`);
|
|
1995
|
+
await say2(client, sessionID, lines.join(`
|
|
1781
1996
|
`));
|
|
1782
1997
|
}
|
|
1783
1998
|
async function initLoop(directory, client, sessionID, args) {
|
|
1784
1999
|
const target = String(args || "").trim() || "progress.md";
|
|
1785
|
-
const full =
|
|
2000
|
+
const full = path7.resolve(directory, target);
|
|
1786
2001
|
if (await pathExists2(full)) {
|
|
1787
2002
|
await toast2(client, `${target} already exists.`, "warning");
|
|
1788
2003
|
return;
|
|
@@ -1808,6 +2023,81 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1808
2023
|
};
|
|
1809
2024
|
}
|
|
1810
2025
|
|
|
2026
|
+
// src/source/core/schedule-syntax.js
|
|
2027
|
+
function removeBooleanFlag(input, flag) {
|
|
2028
|
+
const pattern = new RegExp(`(^|\\s)${flag.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}(?=\\s|$)`, "i");
|
|
2029
|
+
const found = pattern.test(input);
|
|
2030
|
+
return {
|
|
2031
|
+
found,
|
|
2032
|
+
value: String(input || "").replace(pattern, " ").replace(/\s+/g, " ").trim()
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
function firstToken(input) {
|
|
2036
|
+
const match = String(input || "").trim().match(/^(\S+)(?:\s+([\s\S]*))?$/);
|
|
2037
|
+
return match ? { token: match[1], rest: String(match[2] || "").trim() } : { token: "", rest: "" };
|
|
2038
|
+
}
|
|
2039
|
+
function inferredMode(intervalMs, maxRuns) {
|
|
2040
|
+
if (Number(maxRuns || 0) === 1 && Number(intervalMs || 0) > 0)
|
|
2041
|
+
return "once";
|
|
2042
|
+
return Number(intervalMs || 0) === 0 ? "idle" : "interval";
|
|
2043
|
+
}
|
|
2044
|
+
function normalizeLoopScheduleArgs(raw, defaults = {}) {
|
|
2045
|
+
const overlap = removeBooleanFlag(String(raw || "").trim(), "--allow-goal-overlap");
|
|
2046
|
+
let input = overlap.value;
|
|
2047
|
+
const nextDefaults = { ...defaults };
|
|
2048
|
+
let scheduleMode = defaults.scheduleMode;
|
|
2049
|
+
let scheduleSyntax = "legacy";
|
|
2050
|
+
const first = firstToken(input);
|
|
2051
|
+
const keyword = first.token.toLowerCase();
|
|
2052
|
+
if (keyword === "idle") {
|
|
2053
|
+
nextDefaults.intervalMs = 0;
|
|
2054
|
+
nextDefaults.immediate = true;
|
|
2055
|
+
scheduleMode = "idle";
|
|
2056
|
+
scheduleSyntax = "idle";
|
|
2057
|
+
input = first.rest;
|
|
2058
|
+
} else if (keyword === "every" || keyword === "after" || keyword === "in") {
|
|
2059
|
+
const duration = firstToken(first.rest);
|
|
2060
|
+
const intervalMs = parseDuration(duration.token);
|
|
2061
|
+
if (intervalMs === null) {
|
|
2062
|
+
return {
|
|
2063
|
+
ok: false,
|
|
2064
|
+
error: `Invalid ${keyword} schedule. Example: /loop ${keyword === "every" ? "every" : "after"} 5m continue the project`
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
nextDefaults.intervalMs = intervalMs;
|
|
2068
|
+
nextDefaults.immediate = false;
|
|
2069
|
+
input = duration.rest;
|
|
2070
|
+
if (keyword === "every") {
|
|
2071
|
+
scheduleMode = intervalMs === 0 ? "idle" : "interval";
|
|
2072
|
+
scheduleSyntax = "every";
|
|
2073
|
+
} else {
|
|
2074
|
+
nextDefaults.maxRuns = 1;
|
|
2075
|
+
scheduleMode = "once";
|
|
2076
|
+
scheduleSyntax = "after";
|
|
2077
|
+
}
|
|
2078
|
+
} else {
|
|
2079
|
+
const duration = parseDuration(first.token);
|
|
2080
|
+
if (duration !== null) {
|
|
2081
|
+
scheduleMode = duration === 0 ? "idle" : "interval";
|
|
2082
|
+
} else if (nextDefaults.intervalMs === undefined || nextDefaults.intervalMs === null) {
|
|
2083
|
+
nextDefaults.intervalMs = 0;
|
|
2084
|
+
nextDefaults.immediate = nextDefaults.immediate ?? true;
|
|
2085
|
+
scheduleMode = "idle";
|
|
2086
|
+
scheduleSyntax = "idle-shorthand";
|
|
2087
|
+
} else {
|
|
2088
|
+
scheduleMode = scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
return {
|
|
2092
|
+
ok: true,
|
|
2093
|
+
args: input,
|
|
2094
|
+
defaults: nextDefaults,
|
|
2095
|
+
scheduleMode: scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns),
|
|
2096
|
+
scheduleSyntax,
|
|
2097
|
+
allowGoalOverlap: overlap.found || defaults.allowGoalOverlap === true
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
|
|
1811
2101
|
// src/source/opencode/loop-registration.js
|
|
1812
2102
|
var DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180000;
|
|
1813
2103
|
var FALLBACK_ACTIVE_GUARD_MS = 45000;
|
|
@@ -1831,19 +2121,34 @@ function createLoopRegistration(options = {}) {
|
|
|
1831
2121
|
const toast2 = requireFunction4(options.toast, "toast");
|
|
1832
2122
|
const say2 = requireFunction4(options.say, "say");
|
|
1833
2123
|
const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
|
|
2124
|
+
const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs;
|
|
1834
2125
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1835
2126
|
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
1836
2127
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
1837
2128
|
const normalizedModelRef2 = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : normalizedModelRef;
|
|
1838
2129
|
const getSessionExecutionContext2 = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : getSessionExecutionContext;
|
|
2130
|
+
const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession;
|
|
1839
2131
|
const configuredGuard = Number(options.defaultActiveGuardMs);
|
|
1840
2132
|
const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS;
|
|
1841
2133
|
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
1842
|
-
const
|
|
2134
|
+
const normalized = normalizeLoopScheduleArgs2(args, defaults);
|
|
2135
|
+
if (!normalized.ok) {
|
|
2136
|
+
await toast2(client, normalized.error, "warning");
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
const parsed = parseLoopArgs2(normalized.args, normalized.defaults);
|
|
1843
2140
|
if (!parsed.ok) {
|
|
1844
2141
|
await toast2(client, parsed.error, "warning");
|
|
1845
2142
|
return;
|
|
1846
2143
|
}
|
|
2144
|
+
parsed.job.scheduleMode = normalized.scheduleMode;
|
|
2145
|
+
parsed.job.scheduleSyntax = normalized.scheduleSyntax;
|
|
2146
|
+
parsed.job.allowGoalOverlap = normalized.allowGoalOverlap === true;
|
|
2147
|
+
if (normalized.scheduleSyntax === "after") {
|
|
2148
|
+
parsed.job.immediate = false;
|
|
2149
|
+
parsed.job.maxRuns = 1;
|
|
2150
|
+
parsed.job.lastRunAt = Date.parse(parsed.job.createdAt || "") || Date.now();
|
|
2151
|
+
}
|
|
1847
2152
|
const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" };
|
|
1848
2153
|
parsed.job.agent = defaults.agent || executionContext.agent || "build";
|
|
1849
2154
|
parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model;
|
|
@@ -1863,6 +2168,19 @@ function createLoopRegistration(options = {}) {
|
|
|
1863
2168
|
if (!parsed.job.activeRecoveryMs) {
|
|
1864
2169
|
parsed.job.activeRecoveryMs = isGoalJob(parsed.job) ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS : Math.max(defaultActiveGuardMs, Math.min(90000, (parsed.job.intervalMs || 0) + 1e4));
|
|
1865
2170
|
}
|
|
2171
|
+
const promptProducing = actionKind(parsed.job.action, parsed.job) === "prompt";
|
|
2172
|
+
if (promptProducing && !parsed.job.allowGoalOverlap) {
|
|
2173
|
+
const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID);
|
|
2174
|
+
if (dedicatedGoalOwnsContinuation(dedicatedGoal)) {
|
|
2175
|
+
await appendLoopLog2(directory, "goal-overlap-blocked", {
|
|
2176
|
+
sessionID,
|
|
2177
|
+
job: parsed.job.name || parsed.job.id,
|
|
2178
|
+
goal: dedicatedGoal.id
|
|
2179
|
+
});
|
|
2180
|
+
await toast2(client, "Prompt loop not added: dedicated /goal already owns continuation in this session. Pause/finish the Goal, use another session, or pass --allow-goal-overlap intentionally.", "warning");
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
1866
2184
|
if (parsed.job.dryRun) {
|
|
1867
2185
|
await toast2(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
|
|
1868
2186
|
await say2(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
|
|
@@ -1890,7 +2208,13 @@ function createLoopRegistration(options = {}) {
|
|
|
1890
2208
|
if (parsed.job.immediate)
|
|
1891
2209
|
scheduleIdleWork(directory, client, sessionID);
|
|
1892
2210
|
await toast2(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
|
|
1893
|
-
await appendLoopLog2(directory, replaced ? "replace" : "add", {
|
|
2211
|
+
await appendLoopLog2(directory, replaced ? "replace" : "add", {
|
|
2212
|
+
sessionID,
|
|
2213
|
+
job: parsed.job.name || parsed.job.id,
|
|
2214
|
+
label: jobLabel(parsed.job),
|
|
2215
|
+
scheduleMode: parsed.job.scheduleMode,
|
|
2216
|
+
scheduleSyntax: parsed.job.scheduleSyntax
|
|
2217
|
+
});
|
|
1894
2218
|
}
|
|
1895
2219
|
return { addLoop };
|
|
1896
2220
|
}
|
|
@@ -2007,6 +2331,37 @@ function clearSessionActivity(sessionID) {
|
|
|
2007
2331
|
deleteSessionExecutionContext(sessionID);
|
|
2008
2332
|
}
|
|
2009
2333
|
|
|
2334
|
+
// src/source/runtime/scheduler-diagnostics.js
|
|
2335
|
+
var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000;
|
|
2336
|
+
function createSchedulerDiagnostics(options = {}) {
|
|
2337
|
+
const now2 = typeof options.now === "function" ? options.now : Date.now;
|
|
2338
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
|
|
2339
|
+
const configured = Number(options.throttleMs);
|
|
2340
|
+
const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS;
|
|
2341
|
+
const lastLogged = new Map;
|
|
2342
|
+
async function logDeferral(directory, sessionID, reason, extra = {}) {
|
|
2343
|
+
const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`;
|
|
2344
|
+
const current = now2();
|
|
2345
|
+
const previous = Number(lastLogged.get(key) || 0);
|
|
2346
|
+
if (previous > 0 && current - previous < throttleMs)
|
|
2347
|
+
return false;
|
|
2348
|
+
lastLogged.set(key, current);
|
|
2349
|
+
await appendLoopLog2(directory, "deferred", {
|
|
2350
|
+
sessionID,
|
|
2351
|
+
reason,
|
|
2352
|
+
...extra
|
|
2353
|
+
});
|
|
2354
|
+
return true;
|
|
2355
|
+
}
|
|
2356
|
+
function clearSession(sessionID) {
|
|
2357
|
+
const prefix = `${sessionID || "unknown"}:`;
|
|
2358
|
+
for (const key of lastLogged.keys())
|
|
2359
|
+
if (key.startsWith(prefix))
|
|
2360
|
+
lastLogged.delete(key);
|
|
2361
|
+
}
|
|
2362
|
+
return { logDeferral, clearSession };
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2010
2365
|
// src/source/runtime/scheduler.js
|
|
2011
2366
|
var DEFAULT_IDLE_DEBOUNCE_MS = 1200;
|
|
2012
2367
|
var DEFAULT_BUSY_RETRY_MS = 5000;
|
|
@@ -2014,37 +2369,6 @@ var DEFAULT_MIN_DUE_TIMER_MS = 250;
|
|
|
2014
2369
|
var DEFAULT_MAX_DUE_TIMER_MS = 2147000000;
|
|
2015
2370
|
var DEFAULT_HEARTBEAT_MS = 2500;
|
|
2016
2371
|
var DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
2017
|
-
function jobDueAt(job, current = now()) {
|
|
2018
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
2019
|
-
return Infinity;
|
|
2020
|
-
if (!job.enabled || job.paused)
|
|
2021
|
-
return Infinity;
|
|
2022
|
-
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
2023
|
-
return Infinity;
|
|
2024
|
-
if (Number(job.runNowRequestedAt || 0) > 0)
|
|
2025
|
-
return current;
|
|
2026
|
-
if (job.watchPaths?.length)
|
|
2027
|
-
return Infinity;
|
|
2028
|
-
const created = Date.parse(job.createdAt || "");
|
|
2029
|
-
if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs)
|
|
2030
|
-
return current;
|
|
2031
|
-
if (job.intervalMs === 0)
|
|
2032
|
-
return current;
|
|
2033
|
-
if (!job.lastRunAt) {
|
|
2034
|
-
if (job.immediate === false)
|
|
2035
|
-
return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0);
|
|
2036
|
-
return current;
|
|
2037
|
-
}
|
|
2038
|
-
return job.lastRunAt + (job.intervalMs || 0);
|
|
2039
|
-
}
|
|
2040
|
-
function nextDueDelay(state, current = now()) {
|
|
2041
|
-
let soonest = Infinity;
|
|
2042
|
-
for (const job of state.jobs || [])
|
|
2043
|
-
soonest = Math.min(soonest, jobDueAt(job, current));
|
|
2044
|
-
if (!Number.isFinite(soonest))
|
|
2045
|
-
return Infinity;
|
|
2046
|
-
return Math.max(0, soonest - current);
|
|
2047
|
-
}
|
|
2048
2372
|
function createSchedulerRuntime(options = {}) {
|
|
2049
2373
|
const idleTimers = new Map;
|
|
2050
2374
|
const dueTimers = new Map;
|
|
@@ -2072,6 +2396,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2072
2396
|
if (options.toast)
|
|
2073
2397
|
await options.toast(client, message, level);
|
|
2074
2398
|
};
|
|
2399
|
+
const diagnostics = createSchedulerDiagnostics({ appendLoopLog: appendLog, now: clock, throttleMs: options.deferralLogThrottleMs });
|
|
2075
2400
|
function stopHeartbeatIfIdle() {
|
|
2076
2401
|
if (!knownSessions.size && heartbeatTimer) {
|
|
2077
2402
|
clearIntervalFn(heartbeatTimer);
|
|
@@ -2119,11 +2444,13 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2119
2444
|
idleTimers.delete(sessionID);
|
|
2120
2445
|
Promise.resolve().then(async () => {
|
|
2121
2446
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2447
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "idle", retryMs: busyRetryMs });
|
|
2122
2448
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2123
2449
|
return;
|
|
2124
2450
|
}
|
|
2125
2451
|
await options.finalizeActiveRun?.(directory, client, sessionID);
|
|
2126
2452
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2453
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "idle", retryMs: busyRetryMs });
|
|
2127
2454
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2128
2455
|
return;
|
|
2129
2456
|
}
|
|
@@ -2172,11 +2499,13 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2172
2499
|
dueTimers.delete(sessionID);
|
|
2173
2500
|
Promise.resolve().then(async () => {
|
|
2174
2501
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2502
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "due", retryMs: busyRetryMs });
|
|
2175
2503
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2176
2504
|
return;
|
|
2177
2505
|
}
|
|
2178
2506
|
await options.finalizeActiveRun?.(directory, client, sessionID);
|
|
2179
2507
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2508
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "due", retryMs: busyRetryMs });
|
|
2180
2509
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2181
2510
|
return;
|
|
2182
2511
|
}
|
|
@@ -2196,6 +2525,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2196
2525
|
cancelIdleWork(sessionID);
|
|
2197
2526
|
cancelDueWork(sessionID);
|
|
2198
2527
|
stopWatchdog(sessionID);
|
|
2528
|
+
diagnostics.clearSession(sessionID);
|
|
2199
2529
|
knownSessions.delete(sessionID);
|
|
2200
2530
|
stopHeartbeatIfIdle();
|
|
2201
2531
|
}
|
|
@@ -2294,8 +2624,8 @@ ${item.output}`).join(`
|
|
|
2294
2624
|
}
|
|
2295
2625
|
|
|
2296
2626
|
// src/source/runtime/job-workspace.js
|
|
2297
|
-
import { promises as
|
|
2298
|
-
import
|
|
2627
|
+
import { promises as fs7 } from "fs";
|
|
2628
|
+
import path8 from "path";
|
|
2299
2629
|
var MAX_SCAN_FILES = 200;
|
|
2300
2630
|
var MAX_SCAN_BYTES = 2000000;
|
|
2301
2631
|
function requireFunction6(value, name) {
|
|
@@ -2330,7 +2660,7 @@ function createJobWorkspaceRuntime(options = {}) {
|
|
|
2330
2660
|
return await buildGoalPrompt2(directory, job);
|
|
2331
2661
|
const sections = [];
|
|
2332
2662
|
if (job.promptFile) {
|
|
2333
|
-
const text = await readSmallTextFile2(
|
|
2663
|
+
const text = await readSmallTextFile2(path8.resolve(directory, job.promptFile));
|
|
2334
2664
|
if (text.trim())
|
|
2335
2665
|
sections.push(`Instructions from ${job.promptFile}:
|
|
2336
2666
|
${text.trim()}`);
|
|
@@ -2340,7 +2670,7 @@ ${text.trim()}`);
|
|
|
2340
2670
|
if (job.action)
|
|
2341
2671
|
sections.push(decoratePrompt(job));
|
|
2342
2672
|
for (const file of job.includeFiles || []) {
|
|
2343
|
-
const text = await readSmallTextFile2(
|
|
2673
|
+
const text = await readSmallTextFile2(path8.resolve(directory, file), 80000);
|
|
2344
2674
|
if (text.trim())
|
|
2345
2675
|
sections.push(`Context from ${file}:
|
|
2346
2676
|
${text.trim().slice(0, 20000)}`);
|
|
@@ -2372,7 +2702,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2372
2702
|
const snapshot = {};
|
|
2373
2703
|
for (const file of files || []) {
|
|
2374
2704
|
try {
|
|
2375
|
-
const stat = await
|
|
2705
|
+
const stat = await fs7.stat(path8.resolve(directory, file));
|
|
2376
2706
|
snapshot[file] = `${stat.mtimeMs}:${stat.size}`;
|
|
2377
2707
|
} catch {
|
|
2378
2708
|
snapshot[file] = "missing";
|
|
@@ -2392,10 +2722,10 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2392
2722
|
}
|
|
2393
2723
|
async function fileContains(filePath, needle) {
|
|
2394
2724
|
try {
|
|
2395
|
-
const stat = await
|
|
2725
|
+
const stat = await fs7.stat(filePath);
|
|
2396
2726
|
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES)
|
|
2397
2727
|
return false;
|
|
2398
|
-
return (await
|
|
2728
|
+
return (await fs7.readFile(filePath, "utf8")).includes(needle);
|
|
2399
2729
|
} catch {
|
|
2400
2730
|
return false;
|
|
2401
2731
|
}
|
|
@@ -2403,9 +2733,9 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2403
2733
|
async function untilReached(directory, job) {
|
|
2404
2734
|
if (!job.until)
|
|
2405
2735
|
return false;
|
|
2406
|
-
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md",
|
|
2736
|
+
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path8.join(".opencode", "opencode-loop", "until.txt")];
|
|
2407
2737
|
for (const file of files)
|
|
2408
|
-
if (await fileContains(
|
|
2738
|
+
if (await fileContains(path8.resolve(directory, file), job.until))
|
|
2409
2739
|
return true;
|
|
2410
2740
|
let scanned = 0;
|
|
2411
2741
|
async function walk(current) {
|
|
@@ -2413,7 +2743,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2413
2743
|
return false;
|
|
2414
2744
|
let entries;
|
|
2415
2745
|
try {
|
|
2416
|
-
entries = await
|
|
2746
|
+
entries = await fs7.readdir(current, { withFileTypes: true });
|
|
2417
2747
|
} catch {
|
|
2418
2748
|
return false;
|
|
2419
2749
|
}
|
|
@@ -2422,7 +2752,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2422
2752
|
return false;
|
|
2423
2753
|
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
|
|
2424
2754
|
continue;
|
|
2425
|
-
const full =
|
|
2755
|
+
const full = path8.join(current, entry.name);
|
|
2426
2756
|
if (entry.isDirectory()) {
|
|
2427
2757
|
if (await walk(full))
|
|
2428
2758
|
return true;
|
|
@@ -2446,13 +2776,13 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2446
2776
|
if (!status.stdout.trim())
|
|
2447
2777
|
return;
|
|
2448
2778
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2449
|
-
const checkpointDir =
|
|
2779
|
+
const checkpointDir = path8.join(stateDir(directory), "checkpoints", safeID(sessionID));
|
|
2450
2780
|
await ensureDir(checkpointDir);
|
|
2451
2781
|
const diff = await runProcess2("git", ["diff", "--binary"], directory, 120000);
|
|
2452
2782
|
const staged = await runProcess2("git", ["diff", "--cached", "--binary"], directory, 120000);
|
|
2453
2783
|
const prefix = `${timestamp}-${safeID(job.name || job.id)}`;
|
|
2454
|
-
await
|
|
2455
|
-
await
|
|
2784
|
+
await fs7.writeFile(path8.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr);
|
|
2785
|
+
await fs7.writeFile(path8.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}
|
|
2456
2786
|
${staged.stdout}`);
|
|
2457
2787
|
if (job.gitCheckpoint) {
|
|
2458
2788
|
await runProcess2("git", ["add", "-A"], directory, 120000);
|
|
@@ -2591,6 +2921,25 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2591
2921
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
2592
2922
|
return cached === "idle" && seenAt > (active.startedAt || 0);
|
|
2593
2923
|
}
|
|
2924
|
+
async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
|
|
2925
|
+
if (activeRuns.has(sessionID))
|
|
2926
|
+
return false;
|
|
2927
|
+
if (liveType !== "busy" && liveType !== "retry")
|
|
2928
|
+
return false;
|
|
2929
|
+
if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
|
|
2930
|
+
return false;
|
|
2931
|
+
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 });
|
|
2932
|
+
if (completion !== "completed")
|
|
2933
|
+
return false;
|
|
2934
|
+
markSessionStatus(sessionID, "idle");
|
|
2935
|
+
await appendLoopLog2(directory, "status-message-idle-recovery", {
|
|
2936
|
+
sessionID,
|
|
2937
|
+
staleStatus: liveType,
|
|
2938
|
+
statusSeenAt: seenAt,
|
|
2939
|
+
staleForMs: Math.max(0, now2() - seenAt)
|
|
2940
|
+
});
|
|
2941
|
+
return true;
|
|
2942
|
+
}
|
|
2594
2943
|
async function sessionStatusType(client, sessionID, directory, options2 = {}) {
|
|
2595
2944
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
2596
2945
|
markSessionStatus(sessionID, "busy");
|
|
@@ -2604,6 +2953,8 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2604
2953
|
return cached;
|
|
2605
2954
|
const live = await readLiveSessionStatus(client, sessionID, directory);
|
|
2606
2955
|
if (live?.type) {
|
|
2956
|
+
if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
|
|
2957
|
+
return "idle";
|
|
2607
2958
|
if ((live.type === "busy" || live.type === "retry") && options2.recoverStaleActive !== false) {
|
|
2608
2959
|
const active = activeRuns.get(sessionID);
|
|
2609
2960
|
if (active) {
|
|
@@ -2924,7 +3275,7 @@ exit=` + postrun.code + `
|
|
|
2924
3275
|
}
|
|
2925
3276
|
|
|
2926
3277
|
// src/source/runtime/run-admission.js
|
|
2927
|
-
import
|
|
3278
|
+
import path9 from "path";
|
|
2928
3279
|
function requireFunction9(value, label) {
|
|
2929
3280
|
if (typeof value !== "function")
|
|
2930
3281
|
throw new TypeError(`createRunAdmissionRuntime requires ${label}`);
|
|
@@ -2941,26 +3292,8 @@ function createRunAdmissionRuntime(options = {}) {
|
|
|
2941
3292
|
const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob;
|
|
2942
3293
|
const toast2 = typeof options.toast === "function" ? options.toast : toast;
|
|
2943
3294
|
const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
|
|
2944
|
-
function
|
|
2945
|
-
|
|
2946
|
-
const due = (state.jobs || []).filter((job) => {
|
|
2947
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
2948
|
-
return false;
|
|
2949
|
-
if (!job.enabled || job.paused)
|
|
2950
|
-
return false;
|
|
2951
|
-
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
2952
|
-
return false;
|
|
2953
|
-
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs)
|
|
2954
|
-
return true;
|
|
2955
|
-
if (Number(job.runNowRequestedAt || 0) > 0)
|
|
2956
|
-
return true;
|
|
2957
|
-
if (force)
|
|
2958
|
-
return true;
|
|
2959
|
-
if (job.watchPaths?.length)
|
|
2960
|
-
return job.watchTriggered === true;
|
|
2961
|
-
return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs;
|
|
2962
|
-
});
|
|
2963
|
-
return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0));
|
|
3295
|
+
function dueJobs2(state, force = false) {
|
|
3296
|
+
return dueJobs(state, now2(), force);
|
|
2964
3297
|
}
|
|
2965
3298
|
async function reschedule(directory, client, sessionID) {
|
|
2966
3299
|
await scheduleDueWork(directory, client, sessionID);
|
|
@@ -2980,7 +3313,7 @@ function createRunAdmissionRuntime(options = {}) {
|
|
|
2980
3313
|
if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
2981
3314
|
return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime");
|
|
2982
3315
|
}
|
|
2983
|
-
if (job.stopFile && await pathExists2(
|
|
3316
|
+
if (job.stopFile && await pathExists2(path9.resolve(directory, job.stopFile))) {
|
|
2984
3317
|
return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile);
|
|
2985
3318
|
}
|
|
2986
3319
|
if (await untilReached(directory, job)) {
|
|
@@ -3023,7 +3356,7 @@ exit=` + preflight.code + `
|
|
|
3023
3356
|
}
|
|
3024
3357
|
return { admitted: true, job, runNowRequested };
|
|
3025
3358
|
}
|
|
3026
|
-
return { dueJobs, admitJob };
|
|
3359
|
+
return { dueJobs: dueJobs2, admitJob };
|
|
3027
3360
|
}
|
|
3028
3361
|
|
|
3029
3362
|
// src/source/runtime/executor.js
|
|
@@ -3125,7 +3458,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3125
3458
|
toast: toast2,
|
|
3126
3459
|
dangerousShell: dangerousShell2
|
|
3127
3460
|
});
|
|
3128
|
-
const
|
|
3461
|
+
const dueJobs2 = admissionRuntime.dueJobs;
|
|
3129
3462
|
function clearActiveRun(sessionID) {
|
|
3130
3463
|
const active = activeRuns.get(sessionID);
|
|
3131
3464
|
if (active?.timer)
|
|
@@ -3236,7 +3569,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3236
3569
|
candidate.watchTriggered = true;
|
|
3237
3570
|
}
|
|
3238
3571
|
}
|
|
3239
|
-
const due =
|
|
3572
|
+
const due = dueJobs2(state, Boolean(runOptions.force));
|
|
3240
3573
|
if (!due.length) {
|
|
3241
3574
|
await writeState2(directory, sessionID, state);
|
|
3242
3575
|
await reschedule();
|
|
@@ -3343,7 +3676,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3343
3676
|
}
|
|
3344
3677
|
}
|
|
3345
3678
|
return {
|
|
3346
|
-
dueJobs,
|
|
3679
|
+
dueJobs: dueJobs2,
|
|
3347
3680
|
clearActiveRun,
|
|
3348
3681
|
disposeSession,
|
|
3349
3682
|
recoverActiveDispatchFailure,
|