@bybrawe/opencode-loop 0.5.33 → 0.5.35
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 +18 -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/network-recovery-test.mjs +218 -0
- 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/scripts/session-status-test.mjs +18 -4
- package/src/index.js +749 -126
- package/src/source/core/continuation.js +71 -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/executor.js +145 -16
- package/src/source/runtime/loop-diagnostics.js +45 -0
- package/src/source/runtime/network-recovery.js +40 -0
- package/src/source/runtime/run-admission.js +2 -13
- package/src/source/runtime/run-finalization.js +28 -0
- 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 +46 -23
- package/src/source/runtime/terminal-guard.js +56 -0
package/src/index.js
CHANGED
|
@@ -1021,6 +1021,69 @@ 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
|
+
var COMPLETION_BOUNDED_PATTERNS = [
|
|
1037
|
+
/\bbitene kadar\b/i,
|
|
1038
|
+
/\b(?:tamamen|komple) projeyi bitir\b/i,
|
|
1039
|
+
/\bi\u015Fi bitir\b/i,
|
|
1040
|
+
/\buntil (?:it(?:'s| is) )?(?:done|complete|completed|finished)\b/i,
|
|
1041
|
+
/\bfinish (?:the )?(?:project|task|work)\b/i,
|
|
1042
|
+
/\bkeep going until\b/i
|
|
1043
|
+
];
|
|
1044
|
+
var TERMINAL_COMPLETION_PATTERNS = [
|
|
1045
|
+
/proje tamamland[\u0131i](?=\s|[.,;:!\u2014-]|$)/i,
|
|
1046
|
+
/\bproject (?:is )?(?:complete|completed|finished|done)\b/i,
|
|
1047
|
+
/\b(?:task|work) (?:is )?(?:complete|completed|finished|done)\b/i
|
|
1048
|
+
];
|
|
1049
|
+
var TERMINAL_NO_WORK_PATTERNS = [
|
|
1050
|
+
/yap[\u0131i]lacak (?:ba\u015Fka )?i\u015F yok(?=\s|[.,;:!\u2014-]|$)/i,
|
|
1051
|
+
/ba\u015Fka (?:bir )?i\u015F (?:kalmad[\u0131i]|yok)(?=\s|[.,;:!\u2014-]|$)/i,
|
|
1052
|
+
/\bnothing (?:else )?left to do\b/i,
|
|
1053
|
+
/\bno (?:more|remaining) work\b/i,
|
|
1054
|
+
/\bno known (?:bugs|issues)\b/i,
|
|
1055
|
+
/\bzero known (?:bugs|issues)\b/i
|
|
1056
|
+
];
|
|
1057
|
+
var NEXT_WORK_PATTERNS = [
|
|
1058
|
+
/\bnext(?: step| task)?\b/i,
|
|
1059
|
+
/s[\u0131i]radaki(?=\s|[.,;:!\u2014-]|$)/i,
|
|
1060
|
+
/sonraki(?=\s|[.,;:!\u2014-]|$)/i,
|
|
1061
|
+
/kalan (?:i\u015F|i\u015Fler|todo|ad\u0131m)(?=\s|[.,;:!\u2014-]|$)/i,
|
|
1062
|
+
/\bremaining (?:work|task|todo|step)/i,
|
|
1063
|
+
/devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
|
|
1064
|
+
];
|
|
1065
|
+
function isContinuationShorthand(value) {
|
|
1066
|
+
return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
|
|
1067
|
+
}
|
|
1068
|
+
function isCompletionBoundedContinuation(value) {
|
|
1069
|
+
const text = String(value || "").trim();
|
|
1070
|
+
return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
|
|
1071
|
+
}
|
|
1072
|
+
function isTerminalNoWorkReply(value) {
|
|
1073
|
+
const text = String(value || "").trim();
|
|
1074
|
+
if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
|
|
1075
|
+
return false;
|
|
1076
|
+
const completed = TERMINAL_COMPLETION_PATTERNS.some((pattern) => pattern.test(text));
|
|
1077
|
+
const noWork = TERMINAL_NO_WORK_PATTERNS.some((pattern) => pattern.test(text));
|
|
1078
|
+
return completed && noWork;
|
|
1079
|
+
}
|
|
1080
|
+
function continuationProjectInstruction(value) {
|
|
1081
|
+
if (!isContinuationShorthand(value) && !isCompletionBoundedContinuation(value))
|
|
1082
|
+
return "";
|
|
1083
|
+
const finish = isCompletionBoundedContinuation(value) ? " If you believe the project is finished, perform a fresh verification pass before declaring completion; report both that the project is complete and that no work remains only when you have concrete current evidence." : "";
|
|
1084
|
+
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.${finish}`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1024
1087
|
// src/source/core/jobs.js
|
|
1025
1088
|
function presetDefaults(name) {
|
|
1026
1089
|
if (name === "loop-compact")
|
|
@@ -1086,6 +1149,9 @@ function actionKind(action, job = {}) {
|
|
|
1086
1149
|
}
|
|
1087
1150
|
function decoratePrompt(job) {
|
|
1088
1151
|
const additions = [];
|
|
1152
|
+
const continuation = continuationProjectInstruction(job.action);
|
|
1153
|
+
if (continuation)
|
|
1154
|
+
additions.push(continuation);
|
|
1089
1155
|
if (job.progressFile)
|
|
1090
1156
|
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
1157
|
if (job.lastVerifyFailure)
|
|
@@ -1623,8 +1689,185 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1623
1689
|
}
|
|
1624
1690
|
|
|
1625
1691
|
// src/source/opencode/loop-commands.js
|
|
1692
|
+
import { promises as fs6 } from "fs";
|
|
1693
|
+
import path7 from "path";
|
|
1694
|
+
|
|
1695
|
+
// src/source/runtime/companion-goal.js
|
|
1626
1696
|
import { promises as fs4 } from "fs";
|
|
1627
1697
|
import path5 from "path";
|
|
1698
|
+
function goalRoot(directory) {
|
|
1699
|
+
return path5.join(directory, ".opencode", "goals");
|
|
1700
|
+
}
|
|
1701
|
+
async function findDedicatedGoalForSession(directory, sessionID) {
|
|
1702
|
+
if (!directory || !sessionID)
|
|
1703
|
+
return;
|
|
1704
|
+
let names;
|
|
1705
|
+
try {
|
|
1706
|
+
names = await fs4.readdir(goalRoot(directory));
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
if (error?.code === "ENOENT")
|
|
1709
|
+
return;
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
for (const name of names) {
|
|
1713
|
+
if (!name.endsWith(".json"))
|
|
1714
|
+
continue;
|
|
1715
|
+
try {
|
|
1716
|
+
const value = JSON.parse(await fs4.readFile(path5.join(goalRoot(directory), name), "utf8"));
|
|
1717
|
+
if (value?.sessionID === sessionID)
|
|
1718
|
+
return value;
|
|
1719
|
+
} catch {}
|
|
1720
|
+
}
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
function dedicatedGoalOwnsContinuation(goal) {
|
|
1724
|
+
return goal?.status === "active";
|
|
1725
|
+
}
|
|
1726
|
+
function dedicatedGoalSummary(goal) {
|
|
1727
|
+
if (!goal)
|
|
1728
|
+
return "not detected";
|
|
1729
|
+
const id = String(goal.id || "unknown").slice(0, 12);
|
|
1730
|
+
const status = String(goal.status || "unknown");
|
|
1731
|
+
return `${status} (${id})`;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// src/source/runtime/loop-diagnostics.js
|
|
1735
|
+
import { promises as fs5 } from "fs";
|
|
1736
|
+
import path6 from "path";
|
|
1737
|
+
|
|
1738
|
+
// src/source/runtime/schedule-policy.js
|
|
1739
|
+
var TERMINAL_GOAL_STATUSES = new Set(["completed", "blocked", "cleared"]);
|
|
1740
|
+
function inferredScheduleMode(job) {
|
|
1741
|
+
const explicit = String(job?.scheduleMode || "").toLowerCase();
|
|
1742
|
+
if (["idle", "interval", "once", "watch"].includes(explicit))
|
|
1743
|
+
return explicit;
|
|
1744
|
+
if (job?.watchPaths?.length)
|
|
1745
|
+
return "watch";
|
|
1746
|
+
if (Number(job?.maxRuns || 0) === 1 && job?.immediate === false && Number(job?.intervalMs || 0) > 0)
|
|
1747
|
+
return "once";
|
|
1748
|
+
return Number(job?.intervalMs || 0) === 0 ? "idle" : "interval";
|
|
1749
|
+
}
|
|
1750
|
+
function jobRunnable(job) {
|
|
1751
|
+
if (!job)
|
|
1752
|
+
return false;
|
|
1753
|
+
if (isGoalJob(job) && TERMINAL_GOAL_STATUSES.has(job.goalStatus))
|
|
1754
|
+
return false;
|
|
1755
|
+
if (!job.enabled || job.paused)
|
|
1756
|
+
return false;
|
|
1757
|
+
if (Number(job.maxRuns || 0) > 0 && Number(job.runCount || 0) >= Number(job.maxRuns || 0))
|
|
1758
|
+
return false;
|
|
1759
|
+
return true;
|
|
1760
|
+
}
|
|
1761
|
+
function jobDueAt(job, current = Date.now()) {
|
|
1762
|
+
if (!jobRunnable(job))
|
|
1763
|
+
return Infinity;
|
|
1764
|
+
if (Number(job.runNowRequestedAt || 0) > 0)
|
|
1765
|
+
return current;
|
|
1766
|
+
const created = Date.parse(job.createdAt || "");
|
|
1767
|
+
if (Number(job.maxRuntimeMs || 0) > 0 && Number.isFinite(created) && current - created >= Number(job.maxRuntimeMs || 0))
|
|
1768
|
+
return current;
|
|
1769
|
+
if (job.watchPaths?.length)
|
|
1770
|
+
return job.watchTriggered === true ? current : Infinity;
|
|
1771
|
+
const intervalMs = Number(job.intervalMs || 0);
|
|
1772
|
+
if (intervalMs === 0)
|
|
1773
|
+
return current;
|
|
1774
|
+
const lastRunAt = Number(job.lastRunAt || 0);
|
|
1775
|
+
if (!lastRunAt) {
|
|
1776
|
+
if (job.immediate === false)
|
|
1777
|
+
return (Number.isFinite(created) ? created : current) + intervalMs;
|
|
1778
|
+
return current;
|
|
1779
|
+
}
|
|
1780
|
+
return lastRunAt + intervalMs;
|
|
1781
|
+
}
|
|
1782
|
+
function jobIsDue(job, current = Date.now(), force = false) {
|
|
1783
|
+
if (!jobRunnable(job))
|
|
1784
|
+
return false;
|
|
1785
|
+
if (force)
|
|
1786
|
+
return true;
|
|
1787
|
+
return jobDueAt(job, current) <= current;
|
|
1788
|
+
}
|
|
1789
|
+
function dueJobs(state, current = Date.now(), force = false) {
|
|
1790
|
+
return (state?.jobs || []).filter((job) => jobIsDue(job, current, force)).sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0));
|
|
1791
|
+
}
|
|
1792
|
+
function nextDueDelay(state, current = Date.now()) {
|
|
1793
|
+
let soonest = Infinity;
|
|
1794
|
+
for (const job of state?.jobs || [])
|
|
1795
|
+
soonest = Math.min(soonest, jobDueAt(job, current));
|
|
1796
|
+
if (!Number.isFinite(soonest))
|
|
1797
|
+
return Infinity;
|
|
1798
|
+
return Math.max(0, soonest - current);
|
|
1799
|
+
}
|
|
1800
|
+
function scheduleDescription(job) {
|
|
1801
|
+
const mode = inferredScheduleMode(job);
|
|
1802
|
+
const intervalMs = Number(job?.intervalMs || 0);
|
|
1803
|
+
if (mode === "idle")
|
|
1804
|
+
return "every idle";
|
|
1805
|
+
if (mode === "watch")
|
|
1806
|
+
return `on watch: ${(job.watchPaths || []).join(", ")}`;
|
|
1807
|
+
if (mode === "once")
|
|
1808
|
+
return intervalMs > 0 ? `once after ${durationToText(intervalMs)}` : "once on next idle";
|
|
1809
|
+
if (job?.immediate === false)
|
|
1810
|
+
return `every ${durationToText(intervalMs)}, first after ${durationToText(intervalMs)}`;
|
|
1811
|
+
return `every ${durationToText(intervalMs)}, starts on next idle`;
|
|
1812
|
+
}
|
|
1813
|
+
function scheduleState(job, current = Date.now()) {
|
|
1814
|
+
if (!job?.enabled)
|
|
1815
|
+
return "stopped";
|
|
1816
|
+
if (job?.paused)
|
|
1817
|
+
return "paused";
|
|
1818
|
+
if (Number(job?.runNowRequestedAt || 0) > 0)
|
|
1819
|
+
return "due now; waiting for idle";
|
|
1820
|
+
const mode = inferredScheduleMode(job);
|
|
1821
|
+
const dueAt = jobDueAt(job, current);
|
|
1822
|
+
if (!Number.isFinite(dueAt))
|
|
1823
|
+
return mode === "watch" ? "waiting for watched change" : "not scheduled";
|
|
1824
|
+
if (dueAt <= current)
|
|
1825
|
+
return mode === "idle" ? "waiting for idle" : "due; waiting for idle";
|
|
1826
|
+
return `due in ${durationToText(dueAt - current)}`;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/source/runtime/loop-diagnostics.js
|
|
1830
|
+
function describeJobScheduling(job, current = Date.now()) {
|
|
1831
|
+
return {
|
|
1832
|
+
schedule: scheduleDescription(job),
|
|
1833
|
+
state: scheduleState(job, current)
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
async function listPersistedLoopSessions(directory, currentSessionID) {
|
|
1837
|
+
const root = stateDir(directory);
|
|
1838
|
+
let names;
|
|
1839
|
+
try {
|
|
1840
|
+
names = await fs5.readdir(root);
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
if (error?.code === "ENOENT")
|
|
1843
|
+
return [];
|
|
1844
|
+
return [];
|
|
1845
|
+
}
|
|
1846
|
+
const sessions = [];
|
|
1847
|
+
for (const name of names) {
|
|
1848
|
+
if (!name.endsWith(".json"))
|
|
1849
|
+
continue;
|
|
1850
|
+
const sessionID = name.slice(0, -5);
|
|
1851
|
+
try {
|
|
1852
|
+
const parsed = JSON.parse(await fs5.readFile(path6.join(root, name), "utf8"));
|
|
1853
|
+
const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
1854
|
+
const enabled = jobs.filter((job) => job?.enabled !== false && !job?.paused).length;
|
|
1855
|
+
const neverRan = jobs.filter((job) => Number(job?.runCount || 0) === 0).length;
|
|
1856
|
+
sessions.push({
|
|
1857
|
+
sessionID,
|
|
1858
|
+
current: sessionID === currentSessionID,
|
|
1859
|
+
jobs: jobs.length,
|
|
1860
|
+
enabled,
|
|
1861
|
+
neverRan
|
|
1862
|
+
});
|
|
1863
|
+
} catch {
|
|
1864
|
+
sessions.push({ sessionID, current: sessionID === currentSessionID, jobs: 0, enabled: 0, neverRan: 0, corrupt: true });
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return sessions.sort((a, b) => Number(b.current) - Number(a.current) || b.enabled - a.enabled || a.sessionID.localeCompare(b.sessionID));
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// src/source/opencode/loop-commands.js
|
|
1628
1871
|
var SERVICE2 = "opencode-loop";
|
|
1629
1872
|
var DEFAULT_PROGRESS_MD = `# Progress
|
|
1630
1873
|
|
|
@@ -1671,8 +1914,10 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1671
1914
|
const removeState2 = typeof options.removeState === "function" ? options.removeState : removeState;
|
|
1672
1915
|
const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists;
|
|
1673
1916
|
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) =>
|
|
1917
|
+
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs6.readFile(...args);
|
|
1918
|
+
const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs6.writeFile(...args);
|
|
1919
|
+
const listPersistedLoopSessions2 = typeof options.listPersistedLoopSessions === "function" ? options.listPersistedLoopSessions : listPersistedLoopSessions;
|
|
1920
|
+
const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession;
|
|
1676
1921
|
const runtimeVersion = options.runtimeVersion || process.version;
|
|
1677
1922
|
const runtimePlatform = options.runtimePlatform || process.platform;
|
|
1678
1923
|
async function stopLoop(directory, client, sessionID, args) {
|
|
@@ -1704,15 +1949,11 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1704
1949
|
async function statusLoop(directory, client, sessionID) {
|
|
1705
1950
|
const state = await readState2(directory, sessionID);
|
|
1706
1951
|
const jobs = state.jobs || [];
|
|
1952
|
+
const current = now2();
|
|
1707
1953
|
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);
|
|
1954
|
+
const scheduling = describeJobScheduling(job, current);
|
|
1714
1955
|
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} |
|
|
1956
|
+
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
1957
|
}) : ["No active loop jobs."];
|
|
1717
1958
|
await toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
|
|
1718
1959
|
await say2(client, sessionID, `OpenCode loop status:
|
|
@@ -1722,7 +1963,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1722
1963
|
async function logsLoop(directory, client, sessionID) {
|
|
1723
1964
|
let text = "No loop log found.";
|
|
1724
1965
|
try {
|
|
1725
|
-
text = (await readFile(
|
|
1966
|
+
text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
|
|
1726
1967
|
`) || text;
|
|
1727
1968
|
} catch {}
|
|
1728
1969
|
await say2(client, sessionID, `OpenCode loop logs:
|
|
@@ -1731,18 +1972,20 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1731
1972
|
async function helpLoop(client, sessionID) {
|
|
1732
1973
|
await say2(client, sessionID, [
|
|
1733
1974
|
"OpenCode Loop help:",
|
|
1734
|
-
"/loop
|
|
1735
|
-
"/loop
|
|
1736
|
-
"/loop
|
|
1737
|
-
"/loop-
|
|
1738
|
-
"/loop
|
|
1739
|
-
"/loop-
|
|
1740
|
-
|
|
1741
|
-
"/loop-
|
|
1742
|
-
"/loop
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
"/loop
|
|
1975
|
+
"/loop continue the project auto-continue forever whenever the session becomes idle",
|
|
1976
|
+
"/loop idle continue the project explicit form of the same idle loop",
|
|
1977
|
+
"/loop every 5m continue the project recurring timer; first run after 5m, always waits for idle",
|
|
1978
|
+
"/loop after 5m continue the project one-shot delayed prompt; runs once when 5m has passed and session is idle",
|
|
1979
|
+
"/loop 5m continue the project legacy compact form: starts on next idle, then every 5m",
|
|
1980
|
+
"/loop 5m --no-now continue the project legacy recurring form with first run delayed 5m",
|
|
1981
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
1982
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
1983
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
1984
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
1985
|
+
'/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
|
|
1986
|
+
"/loop --prompt-file loop-prompt.md idle loop loading its prompt from a file",
|
|
1987
|
+
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
1988
|
+
"Prompt-producing /loop jobs are blocked while dedicated /goal owns the same session; use another session or --allow-goal-overlap only intentionally.",
|
|
1746
1989
|
"/loop-doctor | /loop-init | /loop-export"
|
|
1747
1990
|
].join(`
|
|
1748
1991
|
`));
|
|
@@ -1767,22 +2010,36 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1767
2010
|
}
|
|
1768
2011
|
async function doctorLoop(directory, client, sessionID) {
|
|
1769
2012
|
const state = await readState2(directory, sessionID);
|
|
1770
|
-
await
|
|
2013
|
+
const persisted = await listPersistedLoopSessions2(directory, sessionID);
|
|
2014
|
+
const otherSessions = persisted.filter((entry) => !entry.current && entry.enabled > 0);
|
|
2015
|
+
const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID);
|
|
2016
|
+
const lines = [
|
|
1771
2017
|
"OpenCode Loop doctor:",
|
|
1772
2018
|
`- plugin: ${SERVICE2}`,
|
|
1773
2019
|
`- project directory: ${directory}`,
|
|
1774
2020
|
`- state directory: ${stateDir(directory)}`,
|
|
1775
|
-
`-
|
|
2021
|
+
`- current session: ${sessionID}`,
|
|
2022
|
+
`- current-session jobs: ${(state.jobs || []).length}`,
|
|
2023
|
+
`- dedicated /goal: ${dedicatedGoalSummary(dedicatedGoal)}`,
|
|
2024
|
+
`- other persisted sessions with enabled jobs: ${otherSessions.length}`,
|
|
1776
2025
|
`- node: ${runtimeVersion}`,
|
|
1777
2026
|
`- platform: ${runtimePlatform}`,
|
|
1778
|
-
"- smoke test: /loop
|
|
2027
|
+
"- smoke test: /loop --max-runs 1 --dry-run continue from progress.md",
|
|
2028
|
+
"- delayed smoke test: /loop after 5m --dry-run continue from progress.md",
|
|
2029
|
+
"- recurring smoke test: /loop every 5m --dry-run continue from progress.md",
|
|
1779
2030
|
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it"
|
|
1780
|
-
]
|
|
2031
|
+
];
|
|
2032
|
+
for (const entry of otherSessions.slice(0, 8)) {
|
|
2033
|
+
lines.push(`- other session ${entry.sessionID}: jobs=${entry.jobs}, enabled=${entry.enabled}, never-ran=${entry.neverRan}`);
|
|
2034
|
+
}
|
|
2035
|
+
if (otherSessions.length > 8)
|
|
2036
|
+
lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`);
|
|
2037
|
+
await say2(client, sessionID, lines.join(`
|
|
1781
2038
|
`));
|
|
1782
2039
|
}
|
|
1783
2040
|
async function initLoop(directory, client, sessionID, args) {
|
|
1784
2041
|
const target = String(args || "").trim() || "progress.md";
|
|
1785
|
-
const full =
|
|
2042
|
+
const full = path7.resolve(directory, target);
|
|
1786
2043
|
if (await pathExists2(full)) {
|
|
1787
2044
|
await toast2(client, `${target} already exists.`, "warning");
|
|
1788
2045
|
return;
|
|
@@ -1808,6 +2065,81 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1808
2065
|
};
|
|
1809
2066
|
}
|
|
1810
2067
|
|
|
2068
|
+
// src/source/core/schedule-syntax.js
|
|
2069
|
+
function removeBooleanFlag(input, flag) {
|
|
2070
|
+
const pattern = new RegExp(`(^|\\s)${flag.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}(?=\\s|$)`, "i");
|
|
2071
|
+
const found = pattern.test(input);
|
|
2072
|
+
return {
|
|
2073
|
+
found,
|
|
2074
|
+
value: String(input || "").replace(pattern, " ").replace(/\s+/g, " ").trim()
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
function firstToken(input) {
|
|
2078
|
+
const match = String(input || "").trim().match(/^(\S+)(?:\s+([\s\S]*))?$/);
|
|
2079
|
+
return match ? { token: match[1], rest: String(match[2] || "").trim() } : { token: "", rest: "" };
|
|
2080
|
+
}
|
|
2081
|
+
function inferredMode(intervalMs, maxRuns) {
|
|
2082
|
+
if (Number(maxRuns || 0) === 1 && Number(intervalMs || 0) > 0)
|
|
2083
|
+
return "once";
|
|
2084
|
+
return Number(intervalMs || 0) === 0 ? "idle" : "interval";
|
|
2085
|
+
}
|
|
2086
|
+
function normalizeLoopScheduleArgs(raw, defaults = {}) {
|
|
2087
|
+
const overlap = removeBooleanFlag(String(raw || "").trim(), "--allow-goal-overlap");
|
|
2088
|
+
let input = overlap.value;
|
|
2089
|
+
const nextDefaults = { ...defaults };
|
|
2090
|
+
let scheduleMode = defaults.scheduleMode;
|
|
2091
|
+
let scheduleSyntax = "legacy";
|
|
2092
|
+
const first = firstToken(input);
|
|
2093
|
+
const keyword = first.token.toLowerCase();
|
|
2094
|
+
if (keyword === "idle") {
|
|
2095
|
+
nextDefaults.intervalMs = 0;
|
|
2096
|
+
nextDefaults.immediate = true;
|
|
2097
|
+
scheduleMode = "idle";
|
|
2098
|
+
scheduleSyntax = "idle";
|
|
2099
|
+
input = first.rest;
|
|
2100
|
+
} else if (keyword === "every" || keyword === "after" || keyword === "in") {
|
|
2101
|
+
const duration = firstToken(first.rest);
|
|
2102
|
+
const intervalMs = parseDuration(duration.token);
|
|
2103
|
+
if (intervalMs === null) {
|
|
2104
|
+
return {
|
|
2105
|
+
ok: false,
|
|
2106
|
+
error: `Invalid ${keyword} schedule. Example: /loop ${keyword === "every" ? "every" : "after"} 5m continue the project`
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
nextDefaults.intervalMs = intervalMs;
|
|
2110
|
+
nextDefaults.immediate = false;
|
|
2111
|
+
input = duration.rest;
|
|
2112
|
+
if (keyword === "every") {
|
|
2113
|
+
scheduleMode = intervalMs === 0 ? "idle" : "interval";
|
|
2114
|
+
scheduleSyntax = "every";
|
|
2115
|
+
} else {
|
|
2116
|
+
nextDefaults.maxRuns = 1;
|
|
2117
|
+
scheduleMode = "once";
|
|
2118
|
+
scheduleSyntax = "after";
|
|
2119
|
+
}
|
|
2120
|
+
} else {
|
|
2121
|
+
const duration = parseDuration(first.token);
|
|
2122
|
+
if (duration !== null) {
|
|
2123
|
+
scheduleMode = duration === 0 ? "idle" : "interval";
|
|
2124
|
+
} else if (nextDefaults.intervalMs === undefined || nextDefaults.intervalMs === null) {
|
|
2125
|
+
nextDefaults.intervalMs = 0;
|
|
2126
|
+
nextDefaults.immediate = nextDefaults.immediate ?? true;
|
|
2127
|
+
scheduleMode = "idle";
|
|
2128
|
+
scheduleSyntax = "idle-shorthand";
|
|
2129
|
+
} else {
|
|
2130
|
+
scheduleMode = scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return {
|
|
2134
|
+
ok: true,
|
|
2135
|
+
args: input,
|
|
2136
|
+
defaults: nextDefaults,
|
|
2137
|
+
scheduleMode: scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns),
|
|
2138
|
+
scheduleSyntax,
|
|
2139
|
+
allowGoalOverlap: overlap.found || defaults.allowGoalOverlap === true
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
|
|
1811
2143
|
// src/source/opencode/loop-registration.js
|
|
1812
2144
|
var DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180000;
|
|
1813
2145
|
var FALLBACK_ACTIVE_GUARD_MS = 45000;
|
|
@@ -1831,19 +2163,34 @@ function createLoopRegistration(options = {}) {
|
|
|
1831
2163
|
const toast2 = requireFunction4(options.toast, "toast");
|
|
1832
2164
|
const say2 = requireFunction4(options.say, "say");
|
|
1833
2165
|
const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
|
|
2166
|
+
const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs;
|
|
1834
2167
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1835
2168
|
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
1836
2169
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
1837
2170
|
const normalizedModelRef2 = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : normalizedModelRef;
|
|
1838
2171
|
const getSessionExecutionContext2 = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : getSessionExecutionContext;
|
|
2172
|
+
const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession;
|
|
1839
2173
|
const configuredGuard = Number(options.defaultActiveGuardMs);
|
|
1840
2174
|
const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS;
|
|
1841
2175
|
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
1842
|
-
const
|
|
2176
|
+
const normalized = normalizeLoopScheduleArgs2(args, defaults);
|
|
2177
|
+
if (!normalized.ok) {
|
|
2178
|
+
await toast2(client, normalized.error, "warning");
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
const parsed = parseLoopArgs2(normalized.args, normalized.defaults);
|
|
1843
2182
|
if (!parsed.ok) {
|
|
1844
2183
|
await toast2(client, parsed.error, "warning");
|
|
1845
2184
|
return;
|
|
1846
2185
|
}
|
|
2186
|
+
parsed.job.scheduleMode = normalized.scheduleMode;
|
|
2187
|
+
parsed.job.scheduleSyntax = normalized.scheduleSyntax;
|
|
2188
|
+
parsed.job.allowGoalOverlap = normalized.allowGoalOverlap === true;
|
|
2189
|
+
if (normalized.scheduleSyntax === "after") {
|
|
2190
|
+
parsed.job.immediate = false;
|
|
2191
|
+
parsed.job.maxRuns = 1;
|
|
2192
|
+
parsed.job.lastRunAt = Date.parse(parsed.job.createdAt || "") || Date.now();
|
|
2193
|
+
}
|
|
1847
2194
|
const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" };
|
|
1848
2195
|
parsed.job.agent = defaults.agent || executionContext.agent || "build";
|
|
1849
2196
|
parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model;
|
|
@@ -1863,6 +2210,19 @@ function createLoopRegistration(options = {}) {
|
|
|
1863
2210
|
if (!parsed.job.activeRecoveryMs) {
|
|
1864
2211
|
parsed.job.activeRecoveryMs = isGoalJob(parsed.job) ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS : Math.max(defaultActiveGuardMs, Math.min(90000, (parsed.job.intervalMs || 0) + 1e4));
|
|
1865
2212
|
}
|
|
2213
|
+
const promptProducing = actionKind(parsed.job.action, parsed.job) === "prompt";
|
|
2214
|
+
if (promptProducing && !parsed.job.allowGoalOverlap) {
|
|
2215
|
+
const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID);
|
|
2216
|
+
if (dedicatedGoalOwnsContinuation(dedicatedGoal)) {
|
|
2217
|
+
await appendLoopLog2(directory, "goal-overlap-blocked", {
|
|
2218
|
+
sessionID,
|
|
2219
|
+
job: parsed.job.name || parsed.job.id,
|
|
2220
|
+
goal: dedicatedGoal.id
|
|
2221
|
+
});
|
|
2222
|
+
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");
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
1866
2226
|
if (parsed.job.dryRun) {
|
|
1867
2227
|
await toast2(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
|
|
1868
2228
|
await say2(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
|
|
@@ -1890,7 +2250,13 @@ function createLoopRegistration(options = {}) {
|
|
|
1890
2250
|
if (parsed.job.immediate)
|
|
1891
2251
|
scheduleIdleWork(directory, client, sessionID);
|
|
1892
2252
|
await toast2(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
|
|
1893
|
-
await appendLoopLog2(directory, replaced ? "replace" : "add", {
|
|
2253
|
+
await appendLoopLog2(directory, replaced ? "replace" : "add", {
|
|
2254
|
+
sessionID,
|
|
2255
|
+
job: parsed.job.name || parsed.job.id,
|
|
2256
|
+
label: jobLabel(parsed.job),
|
|
2257
|
+
scheduleMode: parsed.job.scheduleMode,
|
|
2258
|
+
scheduleSyntax: parsed.job.scheduleSyntax
|
|
2259
|
+
});
|
|
1894
2260
|
}
|
|
1895
2261
|
return { addLoop };
|
|
1896
2262
|
}
|
|
@@ -2007,6 +2373,37 @@ function clearSessionActivity(sessionID) {
|
|
|
2007
2373
|
deleteSessionExecutionContext(sessionID);
|
|
2008
2374
|
}
|
|
2009
2375
|
|
|
2376
|
+
// src/source/runtime/scheduler-diagnostics.js
|
|
2377
|
+
var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000;
|
|
2378
|
+
function createSchedulerDiagnostics(options = {}) {
|
|
2379
|
+
const now2 = typeof options.now === "function" ? options.now : Date.now;
|
|
2380
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
|
|
2381
|
+
const configured = Number(options.throttleMs);
|
|
2382
|
+
const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS;
|
|
2383
|
+
const lastLogged = new Map;
|
|
2384
|
+
async function logDeferral(directory, sessionID, reason, extra = {}) {
|
|
2385
|
+
const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`;
|
|
2386
|
+
const current = now2();
|
|
2387
|
+
const previous = Number(lastLogged.get(key) || 0);
|
|
2388
|
+
if (previous > 0 && current - previous < throttleMs)
|
|
2389
|
+
return false;
|
|
2390
|
+
lastLogged.set(key, current);
|
|
2391
|
+
await appendLoopLog2(directory, "deferred", {
|
|
2392
|
+
sessionID,
|
|
2393
|
+
reason,
|
|
2394
|
+
...extra
|
|
2395
|
+
});
|
|
2396
|
+
return true;
|
|
2397
|
+
}
|
|
2398
|
+
function clearSession(sessionID) {
|
|
2399
|
+
const prefix = `${sessionID || "unknown"}:`;
|
|
2400
|
+
for (const key of lastLogged.keys())
|
|
2401
|
+
if (key.startsWith(prefix))
|
|
2402
|
+
lastLogged.delete(key);
|
|
2403
|
+
}
|
|
2404
|
+
return { logDeferral, clearSession };
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2010
2407
|
// src/source/runtime/scheduler.js
|
|
2011
2408
|
var DEFAULT_IDLE_DEBOUNCE_MS = 1200;
|
|
2012
2409
|
var DEFAULT_BUSY_RETRY_MS = 5000;
|
|
@@ -2014,37 +2411,6 @@ var DEFAULT_MIN_DUE_TIMER_MS = 250;
|
|
|
2014
2411
|
var DEFAULT_MAX_DUE_TIMER_MS = 2147000000;
|
|
2015
2412
|
var DEFAULT_HEARTBEAT_MS = 2500;
|
|
2016
2413
|
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
2414
|
function createSchedulerRuntime(options = {}) {
|
|
2049
2415
|
const idleTimers = new Map;
|
|
2050
2416
|
const dueTimers = new Map;
|
|
@@ -2072,6 +2438,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2072
2438
|
if (options.toast)
|
|
2073
2439
|
await options.toast(client, message, level);
|
|
2074
2440
|
};
|
|
2441
|
+
const diagnostics = createSchedulerDiagnostics({ appendLoopLog: appendLog, now: clock, throttleMs: options.deferralLogThrottleMs });
|
|
2075
2442
|
function stopHeartbeatIfIdle() {
|
|
2076
2443
|
if (!knownSessions.size && heartbeatTimer) {
|
|
2077
2444
|
clearIntervalFn(heartbeatTimer);
|
|
@@ -2119,11 +2486,13 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2119
2486
|
idleTimers.delete(sessionID);
|
|
2120
2487
|
Promise.resolve().then(async () => {
|
|
2121
2488
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2489
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "idle", retryMs: busyRetryMs });
|
|
2122
2490
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2123
2491
|
return;
|
|
2124
2492
|
}
|
|
2125
2493
|
await options.finalizeActiveRun?.(directory, client, sessionID);
|
|
2126
2494
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2495
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "idle", retryMs: busyRetryMs });
|
|
2127
2496
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2128
2497
|
return;
|
|
2129
2498
|
}
|
|
@@ -2172,11 +2541,13 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2172
2541
|
dueTimers.delete(sessionID);
|
|
2173
2542
|
Promise.resolve().then(async () => {
|
|
2174
2543
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2544
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "due", retryMs: busyRetryMs });
|
|
2175
2545
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2176
2546
|
return;
|
|
2177
2547
|
}
|
|
2178
2548
|
await options.finalizeActiveRun?.(directory, client, sessionID);
|
|
2179
2549
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2550
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "due", retryMs: busyRetryMs });
|
|
2180
2551
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2181
2552
|
return;
|
|
2182
2553
|
}
|
|
@@ -2196,6 +2567,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2196
2567
|
cancelIdleWork(sessionID);
|
|
2197
2568
|
cancelDueWork(sessionID);
|
|
2198
2569
|
stopWatchdog(sessionID);
|
|
2570
|
+
diagnostics.clearSession(sessionID);
|
|
2199
2571
|
knownSessions.delete(sessionID);
|
|
2200
2572
|
stopHeartbeatIfIdle();
|
|
2201
2573
|
}
|
|
@@ -2294,8 +2666,8 @@ ${item.output}`).join(`
|
|
|
2294
2666
|
}
|
|
2295
2667
|
|
|
2296
2668
|
// src/source/runtime/job-workspace.js
|
|
2297
|
-
import { promises as
|
|
2298
|
-
import
|
|
2669
|
+
import { promises as fs7 } from "fs";
|
|
2670
|
+
import path8 from "path";
|
|
2299
2671
|
var MAX_SCAN_FILES = 200;
|
|
2300
2672
|
var MAX_SCAN_BYTES = 2000000;
|
|
2301
2673
|
function requireFunction6(value, name) {
|
|
@@ -2330,7 +2702,7 @@ function createJobWorkspaceRuntime(options = {}) {
|
|
|
2330
2702
|
return await buildGoalPrompt2(directory, job);
|
|
2331
2703
|
const sections = [];
|
|
2332
2704
|
if (job.promptFile) {
|
|
2333
|
-
const text = await readSmallTextFile2(
|
|
2705
|
+
const text = await readSmallTextFile2(path8.resolve(directory, job.promptFile));
|
|
2334
2706
|
if (text.trim())
|
|
2335
2707
|
sections.push(`Instructions from ${job.promptFile}:
|
|
2336
2708
|
${text.trim()}`);
|
|
@@ -2340,7 +2712,7 @@ ${text.trim()}`);
|
|
|
2340
2712
|
if (job.action)
|
|
2341
2713
|
sections.push(decoratePrompt(job));
|
|
2342
2714
|
for (const file of job.includeFiles || []) {
|
|
2343
|
-
const text = await readSmallTextFile2(
|
|
2715
|
+
const text = await readSmallTextFile2(path8.resolve(directory, file), 80000);
|
|
2344
2716
|
if (text.trim())
|
|
2345
2717
|
sections.push(`Context from ${file}:
|
|
2346
2718
|
${text.trim().slice(0, 20000)}`);
|
|
@@ -2372,7 +2744,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2372
2744
|
const snapshot = {};
|
|
2373
2745
|
for (const file of files || []) {
|
|
2374
2746
|
try {
|
|
2375
|
-
const stat = await
|
|
2747
|
+
const stat = await fs7.stat(path8.resolve(directory, file));
|
|
2376
2748
|
snapshot[file] = `${stat.mtimeMs}:${stat.size}`;
|
|
2377
2749
|
} catch {
|
|
2378
2750
|
snapshot[file] = "missing";
|
|
@@ -2392,10 +2764,10 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2392
2764
|
}
|
|
2393
2765
|
async function fileContains(filePath, needle) {
|
|
2394
2766
|
try {
|
|
2395
|
-
const stat = await
|
|
2767
|
+
const stat = await fs7.stat(filePath);
|
|
2396
2768
|
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES)
|
|
2397
2769
|
return false;
|
|
2398
|
-
return (await
|
|
2770
|
+
return (await fs7.readFile(filePath, "utf8")).includes(needle);
|
|
2399
2771
|
} catch {
|
|
2400
2772
|
return false;
|
|
2401
2773
|
}
|
|
@@ -2403,9 +2775,9 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2403
2775
|
async function untilReached(directory, job) {
|
|
2404
2776
|
if (!job.until)
|
|
2405
2777
|
return false;
|
|
2406
|
-
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md",
|
|
2778
|
+
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path8.join(".opencode", "opencode-loop", "until.txt")];
|
|
2407
2779
|
for (const file of files)
|
|
2408
|
-
if (await fileContains(
|
|
2780
|
+
if (await fileContains(path8.resolve(directory, file), job.until))
|
|
2409
2781
|
return true;
|
|
2410
2782
|
let scanned = 0;
|
|
2411
2783
|
async function walk(current) {
|
|
@@ -2413,7 +2785,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2413
2785
|
return false;
|
|
2414
2786
|
let entries;
|
|
2415
2787
|
try {
|
|
2416
|
-
entries = await
|
|
2788
|
+
entries = await fs7.readdir(current, { withFileTypes: true });
|
|
2417
2789
|
} catch {
|
|
2418
2790
|
return false;
|
|
2419
2791
|
}
|
|
@@ -2422,7 +2794,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2422
2794
|
return false;
|
|
2423
2795
|
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
|
|
2424
2796
|
continue;
|
|
2425
|
-
const full =
|
|
2797
|
+
const full = path8.join(current, entry.name);
|
|
2426
2798
|
if (entry.isDirectory()) {
|
|
2427
2799
|
if (await walk(full))
|
|
2428
2800
|
return true;
|
|
@@ -2446,13 +2818,13 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2446
2818
|
if (!status.stdout.trim())
|
|
2447
2819
|
return;
|
|
2448
2820
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2449
|
-
const checkpointDir =
|
|
2821
|
+
const checkpointDir = path8.join(stateDir(directory), "checkpoints", safeID(sessionID));
|
|
2450
2822
|
await ensureDir(checkpointDir);
|
|
2451
2823
|
const diff = await runProcess2("git", ["diff", "--binary"], directory, 120000);
|
|
2452
2824
|
const staged = await runProcess2("git", ["diff", "--cached", "--binary"], directory, 120000);
|
|
2453
2825
|
const prefix = `${timestamp}-${safeID(job.name || job.id)}`;
|
|
2454
|
-
await
|
|
2455
|
-
await
|
|
2826
|
+
await fs7.writeFile(path8.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr);
|
|
2827
|
+
await fs7.writeFile(path8.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}
|
|
2456
2828
|
${staged.stdout}`);
|
|
2457
2829
|
if (job.gitCheckpoint) {
|
|
2458
2830
|
await runProcess2("git", ["add", "-A"], directory, 120000);
|
|
@@ -2535,7 +2907,9 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2535
2907
|
if (directory)
|
|
2536
2908
|
argsList.push({ query: { directory } }, { directory }, { workspace: directory });
|
|
2537
2909
|
argsList.push({});
|
|
2910
|
+
let attempted = false;
|
|
2538
2911
|
for (const args of argsList) {
|
|
2912
|
+
attempted = true;
|
|
2539
2913
|
try {
|
|
2540
2914
|
const result = await statusMethod.call(client.session, args);
|
|
2541
2915
|
const error = sdkError(result);
|
|
@@ -2565,7 +2939,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2565
2939
|
return { type: "idle", source: "sdk" };
|
|
2566
2940
|
} catch {}
|
|
2567
2941
|
}
|
|
2568
|
-
return;
|
|
2942
|
+
return attempted ? { type: "unknown", source: "sdk-error" } : undefined;
|
|
2569
2943
|
}
|
|
2570
2944
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options2 = {}) {
|
|
2571
2945
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
|
|
@@ -2577,19 +2951,42 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2577
2951
|
return true;
|
|
2578
2952
|
if (!options2.requireIdle)
|
|
2579
2953
|
return completion === "unknown" && staleActiveRun(sessionID);
|
|
2954
|
+
const cached = sessionStatuses.get(sessionID);
|
|
2955
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
2956
|
+
const cachedIdleAfterRun = cached === "idle" && seenAt > (active.startedAt || 0);
|
|
2580
2957
|
const live = await readLiveSessionStatus(client, sessionID, directory);
|
|
2581
2958
|
if (live?.type === "idle")
|
|
2582
2959
|
return true;
|
|
2960
|
+
if (live?.type === "unknown" && cachedIdleAfterRun) {
|
|
2961
|
+
return true;
|
|
2962
|
+
}
|
|
2583
2963
|
if (live?.type) {
|
|
2584
|
-
if (
|
|
2964
|
+
if (live.type === "busy" && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2585
2965
|
return true;
|
|
2586
2966
|
return false;
|
|
2587
2967
|
}
|
|
2588
2968
|
if (options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2589
2969
|
return true;
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2970
|
+
return cachedIdleAfterRun;
|
|
2971
|
+
}
|
|
2972
|
+
async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
|
|
2973
|
+
if (activeRuns.has(sessionID))
|
|
2974
|
+
return false;
|
|
2975
|
+
if (liveType !== "busy")
|
|
2976
|
+
return false;
|
|
2977
|
+
if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
|
|
2978
|
+
return false;
|
|
2979
|
+
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 });
|
|
2980
|
+
if (completion !== "completed")
|
|
2981
|
+
return false;
|
|
2982
|
+
markSessionStatus(sessionID, "idle");
|
|
2983
|
+
await appendLoopLog2(directory, "status-message-idle-recovery", {
|
|
2984
|
+
sessionID,
|
|
2985
|
+
staleStatus: liveType,
|
|
2986
|
+
statusSeenAt: seenAt,
|
|
2987
|
+
staleForMs: Math.max(0, now2() - seenAt)
|
|
2988
|
+
});
|
|
2989
|
+
return true;
|
|
2593
2990
|
}
|
|
2594
2991
|
async function sessionStatusType(client, sessionID, directory, options2 = {}) {
|
|
2595
2992
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
@@ -2603,18 +3000,27 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2603
3000
|
if (cached && now2() - seenAt < sessionStatusCacheMs)
|
|
2604
3001
|
return cached;
|
|
2605
3002
|
const live = await readLiveSessionStatus(client, sessionID, directory);
|
|
3003
|
+
if (live?.type === "unknown") {
|
|
3004
|
+
const conservative = cached === "retry" ? "retry" : "busy";
|
|
3005
|
+
markSessionStatus(sessionID, conservative);
|
|
3006
|
+
return conservative;
|
|
3007
|
+
}
|
|
2606
3008
|
if (live?.type) {
|
|
3009
|
+
if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
|
|
3010
|
+
return "idle";
|
|
2607
3011
|
if ((live.type === "busy" || live.type === "retry") && options2.recoverStaleActive !== false) {
|
|
2608
3012
|
const active = activeRuns.get(sessionID);
|
|
2609
3013
|
if (active) {
|
|
2610
3014
|
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
|
|
2611
|
-
if (completion === "completed" || completion === "unknown" && staleActiveRun(sessionID)) {
|
|
3015
|
+
if (completion === "completed" || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) {
|
|
2612
3016
|
markSessionStatus(sessionID, "idle");
|
|
2613
|
-
|
|
3017
|
+
const logDetails = {
|
|
2614
3018
|
sessionID,
|
|
2615
3019
|
job: active.job?.name || active.jobId,
|
|
2616
|
-
startedAt: active.startedAt
|
|
2617
|
-
|
|
3020
|
+
startedAt: active.startedAt,
|
|
3021
|
+
...completion === "completed" ? {} : { staleStatus: live.type }
|
|
3022
|
+
};
|
|
3023
|
+
await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", logDetails);
|
|
2618
3024
|
return "idle";
|
|
2619
3025
|
}
|
|
2620
3026
|
}
|
|
@@ -2830,6 +3236,58 @@ ${prompt}`;
|
|
|
2830
3236
|
return { fireAction };
|
|
2831
3237
|
}
|
|
2832
3238
|
|
|
3239
|
+
// src/source/runtime/terminal-guard.js
|
|
3240
|
+
function messageText(message) {
|
|
3241
|
+
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
3242
|
+
const fromParts = parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join(`
|
|
3243
|
+
`).trim();
|
|
3244
|
+
if (fromParts)
|
|
3245
|
+
return fromParts;
|
|
3246
|
+
const info = message?.info || message || {};
|
|
3247
|
+
for (const value of [info.text, info.content, info.summary]) {
|
|
3248
|
+
if (typeof value === "string" && value.trim())
|
|
3249
|
+
return value.trim();
|
|
3250
|
+
}
|
|
3251
|
+
return "";
|
|
3252
|
+
}
|
|
3253
|
+
async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
|
|
3254
|
+
if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
|
|
3255
|
+
return { job, terminal: false, pausedNow: false };
|
|
3256
|
+
}
|
|
3257
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
|
|
3258
|
+
if (!messages)
|
|
3259
|
+
return { job, terminal: false, pausedNow: false };
|
|
3260
|
+
const tail = orderedSessionMessages(messages).at(-1);
|
|
3261
|
+
const info = tail?.info || tail || {};
|
|
3262
|
+
if (info.role !== "assistant")
|
|
3263
|
+
return { job, terminal: false, pausedNow: false };
|
|
3264
|
+
const completed = Number(info?.time?.completed || 0);
|
|
3265
|
+
const created = Number(info?.time?.created || 0);
|
|
3266
|
+
const runStarted = Number(job?.lastRunAt || 0);
|
|
3267
|
+
if (!Number.isFinite(completed) || completed <= 0)
|
|
3268
|
+
return { job, terminal: false, pausedNow: false };
|
|
3269
|
+
if (runStarted > 0 && completed < runStarted && (!Number.isFinite(created) || created < runStarted)) {
|
|
3270
|
+
return { job, terminal: false, pausedNow: false };
|
|
3271
|
+
}
|
|
3272
|
+
const text = messageText(tail);
|
|
3273
|
+
const terminal = isTerminalNoWorkReply(text);
|
|
3274
|
+
if (!terminal) {
|
|
3275
|
+
if (job.terminalNoWorkCount)
|
|
3276
|
+
job.terminalNoWorkCount = 0;
|
|
3277
|
+
return { job, terminal: false, pausedNow: false, text };
|
|
3278
|
+
}
|
|
3279
|
+
job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
|
|
3280
|
+
job.lastTerminalNoWorkAt = Date.now();
|
|
3281
|
+
job.lastTerminalNoWorkSummary = text.slice(0, 1000);
|
|
3282
|
+
const threshold = Math.max(2, Number(options.threshold) || 2);
|
|
3283
|
+
const pausedNow = job.terminalNoWorkCount >= threshold && !job.paused;
|
|
3284
|
+
if (pausedNow) {
|
|
3285
|
+
job.paused = true;
|
|
3286
|
+
job.lastFailureReason = "terminal_no_work";
|
|
3287
|
+
}
|
|
3288
|
+
return { job, terminal: true, pausedNow, text };
|
|
3289
|
+
}
|
|
3290
|
+
|
|
2833
3291
|
// src/source/runtime/run-finalization.js
|
|
2834
3292
|
function requireFunction8(value, label) {
|
|
2835
3293
|
if (typeof value !== "function")
|
|
@@ -2849,7 +3307,14 @@ function createRunFinalizationRuntime(options = {}) {
|
|
|
2849
3307
|
const toast2 = typeof options.toast === "function" ? options.toast : toast;
|
|
2850
3308
|
const writeGoalReport2 = typeof options.writeGoalReport === "function" ? options.writeGoalReport : writeGoalReport;
|
|
2851
3309
|
const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
|
|
3310
|
+
const applyTerminalContinuationGuard2 = typeof options.applyTerminalContinuationGuard === "function" ? options.applyTerminalContinuationGuard : applyTerminalContinuationGuard;
|
|
2852
3311
|
async function finalizeJob(directory, client, sessionID, state, job, previousJob) {
|
|
3312
|
+
if (job.infrastructureFailureCount) {
|
|
3313
|
+
job.infrastructureFailureCount = 0;
|
|
3314
|
+
job.lastInfrastructureFailure = "";
|
|
3315
|
+
job.lastInfrastructureError = "";
|
|
3316
|
+
job.lastInfrastructureFailureAt = 0;
|
|
3317
|
+
}
|
|
2853
3318
|
if (job.verifyCommand) {
|
|
2854
3319
|
const verify = await runShellCommand2(job.verifyCommand, directory, job.timeoutMs || 300000);
|
|
2855
3320
|
job.lastVerifyAt = now2();
|
|
@@ -2912,6 +3377,18 @@ exit=` + postrun.code + `
|
|
|
2912
3377
|
job = await runGoalChecks(directory, sessionID, job, client);
|
|
2913
3378
|
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, previousJob);
|
|
2914
3379
|
}
|
|
3380
|
+
const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
|
|
3381
|
+
job = terminal.job;
|
|
3382
|
+
if (terminal.pausedNow) {
|
|
3383
|
+
await appendLoopLog2(directory, "terminal-no-work", {
|
|
3384
|
+
sessionID,
|
|
3385
|
+
job: job.name || job.id,
|
|
3386
|
+
count: job.terminalNoWorkCount,
|
|
3387
|
+
summary: String(terminal.text || "").slice(0, 1000)
|
|
3388
|
+
});
|
|
3389
|
+
await notifyJob2(directory, job, "terminal_no_work");
|
|
3390
|
+
await toast2(client, "Loop paused: completion-bounded task reported complete with no work remaining twice.", "success");
|
|
3391
|
+
}
|
|
2915
3392
|
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
|
|
2916
3393
|
await writeState2(directory, sessionID, state);
|
|
2917
3394
|
if (isGoalJob(job))
|
|
@@ -2924,7 +3401,7 @@ exit=` + postrun.code + `
|
|
|
2924
3401
|
}
|
|
2925
3402
|
|
|
2926
3403
|
// src/source/runtime/run-admission.js
|
|
2927
|
-
import
|
|
3404
|
+
import path9 from "path";
|
|
2928
3405
|
function requireFunction9(value, label) {
|
|
2929
3406
|
if (typeof value !== "function")
|
|
2930
3407
|
throw new TypeError(`createRunAdmissionRuntime requires ${label}`);
|
|
@@ -2941,26 +3418,8 @@ function createRunAdmissionRuntime(options = {}) {
|
|
|
2941
3418
|
const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob;
|
|
2942
3419
|
const toast2 = typeof options.toast === "function" ? options.toast : toast;
|
|
2943
3420
|
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));
|
|
3421
|
+
function dueJobs2(state, force = false) {
|
|
3422
|
+
return dueJobs(state, now2(), force);
|
|
2964
3423
|
}
|
|
2965
3424
|
async function reschedule(directory, client, sessionID) {
|
|
2966
3425
|
await scheduleDueWork(directory, client, sessionID);
|
|
@@ -2980,7 +3439,7 @@ function createRunAdmissionRuntime(options = {}) {
|
|
|
2980
3439
|
if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
2981
3440
|
return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime");
|
|
2982
3441
|
}
|
|
2983
|
-
if (job.stopFile && await pathExists2(
|
|
3442
|
+
if (job.stopFile && await pathExists2(path9.resolve(directory, job.stopFile))) {
|
|
2984
3443
|
return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile);
|
|
2985
3444
|
}
|
|
2986
3445
|
if (await untilReached(directory, job)) {
|
|
@@ -3023,12 +3482,61 @@ exit=` + preflight.code + `
|
|
|
3023
3482
|
}
|
|
3024
3483
|
return { admitted: true, job, runNowRequested };
|
|
3025
3484
|
}
|
|
3026
|
-
return { dueJobs, admitJob };
|
|
3485
|
+
return { dueJobs: dueJobs2, admitJob };
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
// src/source/runtime/network-recovery.js
|
|
3489
|
+
var TRANSIENT_NETWORK_PATTERNS = [
|
|
3490
|
+
/\b(?:408|425|429|500|502|503|504|524)\b/i,
|
|
3491
|
+
/rate[\s_-]?limit|too many requests|overloaded|service[\s_-]?unavailable|provider[_ -]?unavailable/i,
|
|
3492
|
+
/terminated|fetch failed|failed to fetch|network[\s_-]?error|network connection lost/i,
|
|
3493
|
+
/connection (?:error|refused|lost)|socket (?:hang up|connection was closed)|reset before headers/i,
|
|
3494
|
+
/\b(?:enotfound|eai_again|econnrefused|econnreset|etimedout|ehostunreach|enetunreach|epipe)\b/i,
|
|
3495
|
+
/\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i,
|
|
3496
|
+
/\btimeout(?:error)?\b/i
|
|
3497
|
+
];
|
|
3498
|
+
function errorText(value) {
|
|
3499
|
+
if (value instanceof Error)
|
|
3500
|
+
return `${value.name}: ${value.message}`;
|
|
3501
|
+
if (typeof value === "string")
|
|
3502
|
+
return value;
|
|
3503
|
+
try {
|
|
3504
|
+
return JSON.stringify(value);
|
|
3505
|
+
} catch {
|
|
3506
|
+
return String(value);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
function isTransientNetworkError(value) {
|
|
3510
|
+
const text = errorText(value);
|
|
3511
|
+
return TRANSIENT_NETWORK_PATTERNS.some((pattern) => pattern.test(text));
|
|
3512
|
+
}
|
|
3513
|
+
function networkRetryDelayMs(attempt, baseMs = 5000, maxMs = 60000) {
|
|
3514
|
+
const safeAttempt = Math.max(1, Math.floor(Number(attempt) || 1));
|
|
3515
|
+
const safeBase = Math.max(1, Math.floor(Number(baseMs) || 5000));
|
|
3516
|
+
const safeMax = Math.max(safeBase, Math.floor(Number(maxMs) || 60000));
|
|
3517
|
+
return Math.min(safeMax, safeBase * 2 ** Math.min(8, safeAttempt - 1));
|
|
3518
|
+
}
|
|
3519
|
+
function refundInfrastructureRun(job, snapshot = {}, input = {}) {
|
|
3520
|
+
const chargedCount = Number(snapshot.runCount ?? job.runCount ?? 0);
|
|
3521
|
+
const currentCount = Number(job.runCount || 0);
|
|
3522
|
+
if (chargedCount > 0 && currentCount >= chargedCount)
|
|
3523
|
+
job.runCount = Math.max(0, currentCount - 1);
|
|
3524
|
+
if (Number.isFinite(Number(snapshot.previousLastRunAt)))
|
|
3525
|
+
job.lastRunAt = Number(snapshot.previousLastRunAt);
|
|
3526
|
+
if (snapshot.disabledByMaxRuns && job.maxRuns > 0 && job.runCount < job.maxRuns)
|
|
3527
|
+
job.enabled = true;
|
|
3528
|
+
job.infrastructureFailureCount = (job.infrastructureFailureCount || 0) + 1;
|
|
3529
|
+
job.lastInfrastructureFailure = String(input.reason || "transient_network_failure").slice(0, 120);
|
|
3530
|
+
job.lastInfrastructureError = errorText(input.error).slice(0, 4000);
|
|
3531
|
+
job.lastInfrastructureFailureAt = Number(input.now || Date.now());
|
|
3532
|
+
return job;
|
|
3027
3533
|
}
|
|
3028
3534
|
|
|
3029
3535
|
// src/source/runtime/executor.js
|
|
3030
3536
|
var DEFAULT_ACTIVE_GUARD_MS = 45000;
|
|
3031
3537
|
var DEFAULT_BUSY_RETRY_MS2 = 5000;
|
|
3538
|
+
var DEFAULT_PROVIDER_RETRY_WATCHDOG_MS = 2 * 60000;
|
|
3539
|
+
var DEFAULT_NETWORK_RETRY_MAX_MS = 60000;
|
|
3032
3540
|
function requireFunction10(value, label) {
|
|
3033
3541
|
if (typeof value !== "function")
|
|
3034
3542
|
throw new TypeError(`createLoopExecutor requires ${label}`);
|
|
@@ -3060,8 +3568,11 @@ function createLoopExecutor(options = {}) {
|
|
|
3060
3568
|
const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
|
|
3061
3569
|
const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0 ? Number(options.activeGuardMs) : DEFAULT_ACTIVE_GUARD_MS;
|
|
3062
3570
|
const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0 ? Number(options.busyRetryMs) : DEFAULT_BUSY_RETRY_MS2;
|
|
3571
|
+
const providerRetryWatchdogMs = Number.isFinite(Number(options.providerRetryWatchdogMs)) && Number(options.providerRetryWatchdogMs) > 0 ? Number(options.providerRetryWatchdogMs) : DEFAULT_PROVIDER_RETRY_WATCHDOG_MS;
|
|
3572
|
+
const networkRetryMaxMs = Number.isFinite(Number(options.networkRetryMaxMs)) && Number(options.networkRetryMaxMs) > 0 ? Number(options.networkRetryMaxMs) : DEFAULT_NETWORK_RETRY_MAX_MS;
|
|
3063
3573
|
const activeRuns = new Map;
|
|
3064
3574
|
const runLocks = new Map;
|
|
3575
|
+
const retryRuns = new Map;
|
|
3065
3576
|
const statusRuntime = createSessionStatusRuntime({
|
|
3066
3577
|
activeRuns,
|
|
3067
3578
|
appendLoopLog: appendLoopLog2,
|
|
@@ -3074,6 +3585,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3074
3585
|
updateSessionStatusFromEvent,
|
|
3075
3586
|
staleActiveRun,
|
|
3076
3587
|
canFinalizeActiveRun,
|
|
3588
|
+
sessionStatusType,
|
|
3077
3589
|
sessionIsIdle,
|
|
3078
3590
|
markSessionStatus,
|
|
3079
3591
|
clearSessionStatus
|
|
@@ -3125,13 +3637,14 @@ function createLoopExecutor(options = {}) {
|
|
|
3125
3637
|
toast: toast2,
|
|
3126
3638
|
dangerousShell: dangerousShell2
|
|
3127
3639
|
});
|
|
3128
|
-
const
|
|
3640
|
+
const dueJobs2 = admissionRuntime.dueJobs;
|
|
3129
3641
|
function clearActiveRun(sessionID) {
|
|
3130
3642
|
const active = activeRuns.get(sessionID);
|
|
3131
3643
|
if (active?.timer)
|
|
3132
3644
|
clearTimeout(active.timer);
|
|
3133
3645
|
compactionRuntime.clearForActiveRun(sessionID, active);
|
|
3134
3646
|
activeRuns.delete(sessionID);
|
|
3647
|
+
retryRuns.delete(sessionID);
|
|
3135
3648
|
}
|
|
3136
3649
|
function disposeSession(sessionID) {
|
|
3137
3650
|
clearActiveRun(sessionID);
|
|
@@ -3139,30 +3652,106 @@ function createLoopExecutor(options = {}) {
|
|
|
3139
3652
|
compactionRuntime.clear(sessionID);
|
|
3140
3653
|
clearSessionStatus(sessionID);
|
|
3141
3654
|
}
|
|
3655
|
+
async function persistInfrastructureRefund(directory, sessionID, active, input = {}) {
|
|
3656
|
+
const state = await readState2(directory, sessionID);
|
|
3657
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
|
|
3658
|
+
if (!job)
|
|
3659
|
+
return { job: undefined, delayMs: busyRetryMs };
|
|
3660
|
+
refundInfrastructureRun(job, {
|
|
3661
|
+
runCount: active.job?.runCount,
|
|
3662
|
+
previousLastRunAt: active.previousLastRunAt,
|
|
3663
|
+
disabledByMaxRuns: active.disabledByMaxRuns
|
|
3664
|
+
}, {
|
|
3665
|
+
reason: input.reason,
|
|
3666
|
+
error: input.error,
|
|
3667
|
+
now: now2()
|
|
3668
|
+
});
|
|
3669
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3670
|
+
await writeState2(directory, sessionID, state);
|
|
3671
|
+
const delayMs = networkRetryDelayMs(job.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs);
|
|
3672
|
+
return { job, delayMs };
|
|
3673
|
+
}
|
|
3142
3674
|
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
3143
3675
|
const active = activeRuns.get(sessionID);
|
|
3144
3676
|
if (!active || active.jobId !== jobId || active.runToken !== runToken)
|
|
3145
3677
|
return false;
|
|
3678
|
+
const transient = isTransientNetworkError(error);
|
|
3679
|
+
const snapshot = active;
|
|
3146
3680
|
clearActiveRun(sessionID);
|
|
3147
3681
|
clearSessionStatus(sessionID);
|
|
3148
3682
|
const message = errorMessage(error);
|
|
3149
|
-
|
|
3150
|
-
|
|
3683
|
+
let state = await readState2(directory, sessionID);
|
|
3684
|
+
let job = (state.jobs || []).find((candidate) => candidate.id === jobId);
|
|
3685
|
+
let retryDelay = busyRetryMs;
|
|
3151
3686
|
if (job) {
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
job
|
|
3158
|
-
|
|
3687
|
+
if (transient) {
|
|
3688
|
+
const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
|
|
3689
|
+
reason: "dispatch_failed_transient",
|
|
3690
|
+
error
|
|
3691
|
+
});
|
|
3692
|
+
job = refunded.job;
|
|
3693
|
+
retryDelay = refunded.delayMs;
|
|
3694
|
+
state = await readState2(directory, sessionID);
|
|
3695
|
+
} else {
|
|
3696
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
3697
|
+
job.lastFailureReason = "dispatch_failed";
|
|
3698
|
+
job.lastDispatchFailure = message.slice(0, 4000);
|
|
3699
|
+
job.lastDispatchFailureAt = now2();
|
|
3700
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
3701
|
+
job.paused = true;
|
|
3702
|
+
await notifyJob2(directory, job, "dispatch_failed");
|
|
3703
|
+
}
|
|
3704
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3705
|
+
await writeState2(directory, sessionID, state);
|
|
3159
3706
|
}
|
|
3160
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3161
|
-
await writeState2(directory, sessionID, state);
|
|
3162
3707
|
}
|
|
3163
|
-
await appendLoopLog2(directory, "dispatch-error"
|
|
3164
|
-
|
|
3165
|
-
|
|
3708
|
+
await appendLoopLog2(directory, transient ? "network-dispatch-error" : "dispatch-error", {
|
|
3709
|
+
sessionID,
|
|
3710
|
+
job: job?.name || jobId,
|
|
3711
|
+
error: message,
|
|
3712
|
+
...transient ? { retryInMs: retryDelay } : {}
|
|
3713
|
+
});
|
|
3714
|
+
if (transient) {
|
|
3715
|
+
await toast2(client, `Loop network dispatch failed; retrying when the session recovers: ${message}`, "warning");
|
|
3716
|
+
} else {
|
|
3717
|
+
await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
|
|
3718
|
+
}
|
|
3719
|
+
await scheduleDueWork(directory, client, sessionID, retryDelay);
|
|
3720
|
+
return true;
|
|
3721
|
+
}
|
|
3722
|
+
async function recoverStuckProviderRetry(directory, client, sessionID) {
|
|
3723
|
+
const active = activeRuns.get(sessionID);
|
|
3724
|
+
if (!active || active.compactionOnly) {
|
|
3725
|
+
retryRuns.delete(sessionID);
|
|
3726
|
+
return false;
|
|
3727
|
+
}
|
|
3728
|
+
const current = retryRuns.get(sessionID);
|
|
3729
|
+
if (!current || current.runToken !== active.runToken) {
|
|
3730
|
+
retryRuns.set(sessionID, { runToken: active.runToken, since: now2() });
|
|
3731
|
+
return false;
|
|
3732
|
+
}
|
|
3733
|
+
if (now2() - current.since < providerRetryWatchdogMs)
|
|
3734
|
+
return false;
|
|
3735
|
+
const snapshot = active;
|
|
3736
|
+
try {
|
|
3737
|
+
if (client?.session?.abort) {
|
|
3738
|
+
await fireSdk2(client, "session.abort provider retry watchdog", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
|
|
3739
|
+
}
|
|
3740
|
+
} catch {}
|
|
3741
|
+
clearActiveRun(sessionID);
|
|
3742
|
+
clearSessionStatus(sessionID);
|
|
3743
|
+
const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
|
|
3744
|
+
reason: "provider_retry_watchdog",
|
|
3745
|
+
error: `OpenCode session.status stayed retry for ${providerRetryWatchdogMs}ms`
|
|
3746
|
+
});
|
|
3747
|
+
await appendLoopLog2(directory, "provider-retry-recovery", {
|
|
3748
|
+
sessionID,
|
|
3749
|
+
job: refunded.job?.name || snapshot.jobId,
|
|
3750
|
+
retryForMs: now2() - current.since,
|
|
3751
|
+
retryInMs: refunded.delayMs
|
|
3752
|
+
});
|
|
3753
|
+
await toast2(client, "Loop provider retry exceeded the watchdog; the Loop-owned turn was released and will retry with backoff.", "warning");
|
|
3754
|
+
await scheduleDueWork(directory, client, sessionID, refunded.delayMs);
|
|
3166
3755
|
return true;
|
|
3167
3756
|
}
|
|
3168
3757
|
async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {
|
|
@@ -3213,14 +3802,24 @@ function createLoopExecutor(options = {}) {
|
|
|
3213
3802
|
}
|
|
3214
3803
|
runLocks.set(sessionID, now2());
|
|
3215
3804
|
let job;
|
|
3805
|
+
let previousLastRunAt = 0;
|
|
3806
|
+
let disabledByMaxRuns = false;
|
|
3216
3807
|
try {
|
|
3217
3808
|
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true });
|
|
3218
|
-
|
|
3809
|
+
const statusType = await sessionStatusType(client, sessionID, directory);
|
|
3810
|
+
if (statusType !== "idle") {
|
|
3811
|
+
if (statusType === "retry") {
|
|
3812
|
+
if (await recoverStuckProviderRetry(directory, client, sessionID))
|
|
3813
|
+
return;
|
|
3814
|
+
} else {
|
|
3815
|
+
retryRuns.delete(sessionID);
|
|
3816
|
+
}
|
|
3219
3817
|
if (runOptions.force)
|
|
3220
3818
|
await toast2(client, "Loop queued: session is busy; it will run on the next idle check.", "info");
|
|
3221
3819
|
await reschedule(busyRetryMs);
|
|
3222
3820
|
return;
|
|
3223
3821
|
}
|
|
3822
|
+
retryRuns.delete(sessionID);
|
|
3224
3823
|
const active = activeRuns.get(sessionID);
|
|
3225
3824
|
const activeAge = active ? now2() - (active.startedAt || 0) : 0;
|
|
3226
3825
|
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || activeGuardMs;
|
|
@@ -3236,7 +3835,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3236
3835
|
candidate.watchTriggered = true;
|
|
3237
3836
|
}
|
|
3238
3837
|
}
|
|
3239
|
-
const due =
|
|
3838
|
+
const due = dueJobs2(state, Boolean(runOptions.force));
|
|
3240
3839
|
if (!due.length) {
|
|
3241
3840
|
await writeState2(directory, sessionID, state);
|
|
3242
3841
|
await reschedule();
|
|
@@ -3274,9 +3873,11 @@ function createLoopExecutor(options = {}) {
|
|
|
3274
3873
|
if (runNowRequested)
|
|
3275
3874
|
delete job.runNowRequestedAt;
|
|
3276
3875
|
job.watchTriggered = false;
|
|
3876
|
+
previousLastRunAt = Number(job.lastRunAt || 0);
|
|
3277
3877
|
job.lastRunAt = now2();
|
|
3278
3878
|
job.runCount = (job.runCount || 0) + 1;
|
|
3279
|
-
|
|
3879
|
+
disabledByMaxRuns = job.maxRuns > 0 && job.runCount >= job.maxRuns;
|
|
3880
|
+
if (disabledByMaxRuns) {
|
|
3280
3881
|
job.enabled = false;
|
|
3281
3882
|
await notifyJob2(directory, job, "max_runs_reached");
|
|
3282
3883
|
}
|
|
@@ -3315,7 +3916,9 @@ function createLoopExecutor(options = {}) {
|
|
|
3315
3916
|
startedAt: now2(),
|
|
3316
3917
|
timer,
|
|
3317
3918
|
runToken,
|
|
3318
|
-
compactionAction: result.compaction === true
|
|
3919
|
+
compactionAction: result.compaction === true,
|
|
3920
|
+
previousLastRunAt,
|
|
3921
|
+
disabledByMaxRuns
|
|
3319
3922
|
});
|
|
3320
3923
|
if (result.compaction && compactionRuntime.isCompleted(sessionID, job.id)) {
|
|
3321
3924
|
await compactionRuntime.finalize(directory, client, sessionID);
|
|
@@ -3330,6 +3933,24 @@ function createLoopExecutor(options = {}) {
|
|
|
3330
3933
|
await reschedule(busyRetryMs);
|
|
3331
3934
|
} catch (error) {
|
|
3332
3935
|
clearActiveRun(sessionID);
|
|
3936
|
+
if (isTransientNetworkError(error) && job) {
|
|
3937
|
+
const stateAfterFailure = await readState2(directory, sessionID);
|
|
3938
|
+
const persisted = (stateAfterFailure.jobs || []).find((candidate) => candidate.id === job.id);
|
|
3939
|
+
if (persisted) {
|
|
3940
|
+
refundInfrastructureRun(persisted, {
|
|
3941
|
+
runCount: job.runCount,
|
|
3942
|
+
previousLastRunAt,
|
|
3943
|
+
disabledByMaxRuns
|
|
3944
|
+
}, { reason: "action_dispatch_transient", error, now: now2() });
|
|
3945
|
+
stateAfterFailure.jobs = (stateAfterFailure.jobs || []).map((candidate) => candidate.id === persisted.id ? persisted : candidate);
|
|
3946
|
+
await writeState2(directory, sessionID, stateAfterFailure);
|
|
3947
|
+
const delayMs = networkRetryDelayMs(persisted.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs);
|
|
3948
|
+
await appendLoopLog2(directory, "network-action-error", { sessionID, job: persisted.name || persisted.id, error: errorMessage(error), retryInMs: delayMs });
|
|
3949
|
+
await toast2(client, "Loop action hit a transient network failure; the logical run was refunded and will retry.", "warning");
|
|
3950
|
+
await reschedule(delayMs);
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3333
3954
|
await toast2(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3334
3955
|
await appendLoopLog2(directory, "error", {
|
|
3335
3956
|
sessionID,
|
|
@@ -3343,13 +3964,15 @@ function createLoopExecutor(options = {}) {
|
|
|
3343
3964
|
}
|
|
3344
3965
|
}
|
|
3345
3966
|
return {
|
|
3346
|
-
dueJobs,
|
|
3967
|
+
dueJobs: dueJobs2,
|
|
3347
3968
|
clearActiveRun,
|
|
3348
3969
|
disposeSession,
|
|
3349
3970
|
recoverActiveDispatchFailure,
|
|
3971
|
+
recoverStuckProviderRetry,
|
|
3350
3972
|
finalizeActiveRun,
|
|
3351
3973
|
fireAction,
|
|
3352
3974
|
maybeRunDueJobs,
|
|
3975
|
+
sessionStatusType,
|
|
3353
3976
|
sessionIsIdle,
|
|
3354
3977
|
updateSessionStatusFromEvent,
|
|
3355
3978
|
markSessionStatus,
|