@bybrawe/opencode-loop 0.5.36 → 0.5.38
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 +26 -0
- package/README.md +1 -1
- package/package.json +7 -6
- package/scripts/comprehensive-test.mjs +7 -4
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/install-node.mjs +4 -2
- package/scripts/install-test.mjs +5 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode2-loop-canary.mjs +48 -7
- package/scripts/publish-workflow-test.mjs +5 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +30 -0
- package/scripts/smoke-test.mjs +21 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +257 -96
- package/src/server.js +6451 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/legacy-v1.js +34 -6
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +44 -8
- package/src/source/opencode2/events.js +11 -0
- package/src/source/opencode2/experimental.js +51 -7
- package/src/source/opencode2/prompt-runtime.js +36 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +5 -1
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +18 -0
package/src/index.js
CHANGED
|
@@ -926,9 +926,9 @@ function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
|
926
926
|
const entry = loopOwnedUserMessageGuards.get(sessionID);
|
|
927
927
|
if (!entry || typeof entry !== "object")
|
|
928
928
|
return false;
|
|
929
|
-
for (const [
|
|
929
|
+
for (const [id, expiresAt] of entry.messageIDs.entries())
|
|
930
930
|
if (expiresAt < now())
|
|
931
|
-
entry.messageIDs.delete(
|
|
931
|
+
entry.messageIDs.delete(id);
|
|
932
932
|
const id = typeof messageID === "string" ? messageID : "";
|
|
933
933
|
if (id && entry.messageIDs.has(id))
|
|
934
934
|
return true;
|
|
@@ -1086,6 +1086,19 @@ var NEXT_WORK_PATTERNS = [
|
|
|
1086
1086
|
/\bremaining (?:work|task|todo|step)/i,
|
|
1087
1087
|
/devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
|
|
1088
1088
|
];
|
|
1089
|
+
var WAITING_USER_PATTERNS = [
|
|
1090
|
+
/(?:^|\n)\s*onay bekleyenler\s*:/i,
|
|
1091
|
+
/(?:^|\n)\s*(?:kullan\u0131c\u0131|user) (?:onay\u0131|onayi|aksiyonu|i\u015Flemi|islemi|eri\u015Fimi|erisimi) (?:bekleniyor|gerekiyor|gerekli)\b/i,
|
|
1092
|
+
/\b(?:senin|sizin) (?:onay\u0131n|onay\u0131n\u0131z|onayiniz|aksiyonun|aksiyonunuz|eri\u015Fimin|erisimin) (?:bekleniyor|gerekiyor|gerekli)\b/i,
|
|
1093
|
+
/\b(?:onay|eri\u015Fim|erisim|yetki) olmadan (?:devam edemem|ilerleyemem|i\u015Flem yapamam|islem yapamam)\b/i,
|
|
1094
|
+
/(?:^|\n)\s*(?:waiting (?:for|on) (?:your|user) (?:approval|input|action|access|credentials?)|(?:approval|user action|access) (?:required|pending))\s*:?/i,
|
|
1095
|
+
/\b(?:requires?|needs?) (?:your|user) (?:approval|input|action|access|credentials?)\b/i,
|
|
1096
|
+
/\bblocked (?:pending|until|on) (?:your|user|approval|access|credentials?)\b/i
|
|
1097
|
+
];
|
|
1098
|
+
var WAITING_USER_NEGATIONS = [
|
|
1099
|
+
/\b(?:onay|eri\u015Fim|erisim|yetki) (?:gerekmiyor|gerekli de\u011Fil|gerekli degil|beklenmiyor)\b/i,
|
|
1100
|
+
/\bno (?:user )?(?:approval|input|action|access|credentials?) (?:is )?(?:needed|required|pending)\b/i
|
|
1101
|
+
];
|
|
1089
1102
|
function isContinuationShorthand(value) {
|
|
1090
1103
|
return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
|
|
1091
1104
|
}
|
|
@@ -1093,6 +1106,16 @@ function isCompletionBoundedContinuation(value) {
|
|
|
1093
1106
|
const text = String(value || "").trim();
|
|
1094
1107
|
return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
|
|
1095
1108
|
}
|
|
1109
|
+
function isWaitingUserReply(value) {
|
|
1110
|
+
const text = String(value || "").trim();
|
|
1111
|
+
if (!text)
|
|
1112
|
+
return false;
|
|
1113
|
+
if (WAITING_USER_NEGATIONS.some((pattern) => pattern.test(text)))
|
|
1114
|
+
return false;
|
|
1115
|
+
if (NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
|
|
1116
|
+
return false;
|
|
1117
|
+
return WAITING_USER_PATTERNS.some((pattern) => pattern.test(text));
|
|
1118
|
+
}
|
|
1096
1119
|
function isTerminalNoWorkReply(value) {
|
|
1097
1120
|
const text = String(value || "").trim();
|
|
1098
1121
|
if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
|
|
@@ -1617,8 +1640,8 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1617
1640
|
const addLoop = requireFunction2(options.addLoop, "addLoop");
|
|
1618
1641
|
const scheduleDueWork = requireFunction2(options.scheduleDueWork, "scheduleDueWork");
|
|
1619
1642
|
const scheduleIdleWork = requireFunction2(options.scheduleIdleWork, "scheduleIdleWork");
|
|
1620
|
-
const
|
|
1621
|
-
const
|
|
1643
|
+
const toast = requireFunction2(options.toast, "toast");
|
|
1644
|
+
const say = requireFunction2(options.say, "say");
|
|
1622
1645
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1623
1646
|
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
1624
1647
|
const setGoalComplete2 = typeof options.setGoalComplete === "function" ? options.setGoalComplete : setGoalComplete;
|
|
@@ -1635,8 +1658,8 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1635
1658
|
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : "";
|
|
1636
1659
|
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}${noProgress}${rejected}`;
|
|
1637
1660
|
}) : ["No experimental goal jobs."];
|
|
1638
|
-
await
|
|
1639
|
-
await
|
|
1661
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning");
|
|
1662
|
+
await say(client, sessionID, `OpenCode Loop experimental goal status:
|
|
1640
1663
|
` + lines.join(`
|
|
1641
1664
|
`));
|
|
1642
1665
|
}
|
|
@@ -1647,7 +1670,7 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1647
1670
|
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job);
|
|
1648
1671
|
await writeState2(directory, sessionID, state);
|
|
1649
1672
|
await scheduleDueWork(directory, client, sessionID);
|
|
1650
|
-
await
|
|
1673
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
1651
1674
|
}
|
|
1652
1675
|
async function resumeGoal(directory, client, sessionID, args) {
|
|
1653
1676
|
const target = String(args || "").trim() || "goal";
|
|
@@ -1660,7 +1683,7 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1660
1683
|
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : job.goalStatus || "active", lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" };
|
|
1661
1684
|
});
|
|
1662
1685
|
await writeState2(directory, sessionID, state);
|
|
1663
|
-
await
|
|
1686
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
1664
1687
|
if (count) {
|
|
1665
1688
|
await scheduleDueWork(directory, client, sessionID);
|
|
1666
1689
|
scheduleIdleWork(directory, client, sessionID);
|
|
@@ -1673,15 +1696,15 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1673
1696
|
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || target && !matchJob(job, target, index));
|
|
1674
1697
|
await writeState2(directory, sessionID, state);
|
|
1675
1698
|
await scheduleDueWork(directory, client, sessionID);
|
|
1676
|
-
await
|
|
1699
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
|
|
1677
1700
|
}
|
|
1678
1701
|
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
1679
1702
|
const result = await setGoalComplete2(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true });
|
|
1680
|
-
await
|
|
1703
|
+
await toast(client, result.message, result.ok ? "success" : "warning");
|
|
1681
1704
|
}
|
|
1682
1705
|
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
1683
1706
|
const result = await setGoalBlocked2(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." });
|
|
1684
|
-
await
|
|
1707
|
+
await toast(client, result.message, "warning");
|
|
1685
1708
|
}
|
|
1686
1709
|
async function addGoal(directory, client, sessionID, args) {
|
|
1687
1710
|
const text = String(args || "").trim();
|
|
@@ -1930,8 +1953,8 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1930
1953
|
const stopWatchdog = requireFunction3(options.stopWatchdog, "stopWatchdog");
|
|
1931
1954
|
const scheduleDueWork = requireFunction3(options.scheduleDueWork, "scheduleDueWork");
|
|
1932
1955
|
const maybeRunDueJobs = requireFunction3(options.maybeRunDueJobs, "maybeRunDueJobs");
|
|
1933
|
-
const
|
|
1934
|
-
const
|
|
1956
|
+
const toast = requireFunction3(options.toast, "toast");
|
|
1957
|
+
const say = requireFunction3(options.say, "say");
|
|
1935
1958
|
const now2 = typeof options.now === "function" ? options.now : now;
|
|
1936
1959
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1937
1960
|
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
@@ -1951,7 +1974,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1951
1974
|
clearActiveRun(sessionID);
|
|
1952
1975
|
cancelDueWork(sessionID);
|
|
1953
1976
|
stopWatchdog(sessionID);
|
|
1954
|
-
await
|
|
1977
|
+
await toast(client, "All loops stopped for this session.", "success");
|
|
1955
1978
|
return;
|
|
1956
1979
|
}
|
|
1957
1980
|
const state = await readState2(directory, sessionID);
|
|
@@ -1959,7 +1982,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1959
1982
|
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index));
|
|
1960
1983
|
await writeState2(directory, sessionID, state);
|
|
1961
1984
|
await scheduleDueWork(directory, client, sessionID);
|
|
1962
|
-
await
|
|
1985
|
+
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
|
|
1963
1986
|
}
|
|
1964
1987
|
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
1965
1988
|
const target = String(args || "").trim() || "all";
|
|
@@ -1968,7 +1991,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1968
1991
|
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job);
|
|
1969
1992
|
await writeState2(directory, sessionID, state);
|
|
1970
1993
|
await scheduleDueWork(directory, client, sessionID);
|
|
1971
|
-
await
|
|
1994
|
+
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
|
|
1972
1995
|
}
|
|
1973
1996
|
async function statusLoop(directory, client, sessionID) {
|
|
1974
1997
|
const state = await readState2(directory, sessionID);
|
|
@@ -1979,8 +2002,8 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1979
2002
|
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(",");
|
|
1980
2003
|
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}`;
|
|
1981
2004
|
}) : ["No active loop jobs."];
|
|
1982
|
-
await
|
|
1983
|
-
await
|
|
2005
|
+
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
|
|
2006
|
+
await say(client, sessionID, `OpenCode loop status:
|
|
1984
2007
|
` + lines.join(`
|
|
1985
2008
|
`));
|
|
1986
2009
|
}
|
|
@@ -1990,11 +2013,11 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1990
2013
|
text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
|
|
1991
2014
|
`) || text;
|
|
1992
2015
|
} catch {}
|
|
1993
|
-
await
|
|
2016
|
+
await say(client, sessionID, `OpenCode loop logs:
|
|
1994
2017
|
` + text);
|
|
1995
2018
|
}
|
|
1996
2019
|
async function helpLoop(client, sessionID) {
|
|
1997
|
-
await
|
|
2020
|
+
await say(client, sessionID, [
|
|
1998
2021
|
"OpenCode Loop help:",
|
|
1999
2022
|
"/loop continue the project auto-continue forever whenever the session becomes idle",
|
|
2000
2023
|
"/loop idle continue the project explicit form of the same idle loop",
|
|
@@ -2028,7 +2051,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
2028
2051
|
count += 1;
|
|
2029
2052
|
}
|
|
2030
2053
|
await writeState2(directory, sessionID, state);
|
|
2031
|
-
await
|
|
2054
|
+
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
|
|
2032
2055
|
if (count)
|
|
2033
2056
|
await scheduleDueWork(directory, client, sessionID);
|
|
2034
2057
|
}
|
|
@@ -2058,23 +2081,23 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
2058
2081
|
}
|
|
2059
2082
|
if (otherSessions.length > 8)
|
|
2060
2083
|
lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`);
|
|
2061
|
-
await
|
|
2084
|
+
await say(client, sessionID, lines.join(`
|
|
2062
2085
|
`));
|
|
2063
2086
|
}
|
|
2064
2087
|
async function initLoop(directory, client, sessionID, args) {
|
|
2065
2088
|
const target = String(args || "").trim() || "progress.md";
|
|
2066
2089
|
const full = path7.resolve(directory, target);
|
|
2067
2090
|
if (await pathExists2(full)) {
|
|
2068
|
-
await
|
|
2091
|
+
await toast(client, `${target} already exists.`, "warning");
|
|
2069
2092
|
return;
|
|
2070
2093
|
}
|
|
2071
2094
|
await writeFile(full, DEFAULT_PROGRESS_MD, "utf8");
|
|
2072
|
-
await
|
|
2095
|
+
await toast(client, `Created ${target}.`, "success");
|
|
2073
2096
|
await appendLoopLog2(directory, "init", { sessionID, file: target });
|
|
2074
2097
|
}
|
|
2075
2098
|
async function exportLoop(directory, client, sessionID) {
|
|
2076
2099
|
const state = await readState2(directory, sessionID);
|
|
2077
|
-
await
|
|
2100
|
+
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
|
|
2078
2101
|
}
|
|
2079
2102
|
return {
|
|
2080
2103
|
stopLoop,
|
|
@@ -2184,8 +2207,8 @@ function createLoopRegistration(options = {}) {
|
|
|
2184
2207
|
const snapshotPaths = requireFunction4(options.snapshotPaths, "snapshotPaths");
|
|
2185
2208
|
const scheduleDueWork = requireFunction4(options.scheduleDueWork, "scheduleDueWork");
|
|
2186
2209
|
const scheduleIdleWork = requireFunction4(options.scheduleIdleWork, "scheduleIdleWork");
|
|
2187
|
-
const
|
|
2188
|
-
const
|
|
2210
|
+
const toast = requireFunction4(options.toast, "toast");
|
|
2211
|
+
const say = requireFunction4(options.say, "say");
|
|
2189
2212
|
const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
|
|
2190
2213
|
const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs;
|
|
2191
2214
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
@@ -2199,12 +2222,12 @@ function createLoopRegistration(options = {}) {
|
|
|
2199
2222
|
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
2200
2223
|
const normalized = normalizeLoopScheduleArgs2(args, defaults);
|
|
2201
2224
|
if (!normalized.ok) {
|
|
2202
|
-
await
|
|
2225
|
+
await toast(client, normalized.error, "warning");
|
|
2203
2226
|
return;
|
|
2204
2227
|
}
|
|
2205
2228
|
const parsed = parseLoopArgs2(normalized.args, normalized.defaults);
|
|
2206
2229
|
if (!parsed.ok) {
|
|
2207
|
-
await
|
|
2230
|
+
await toast(client, parsed.error, "warning");
|
|
2208
2231
|
return;
|
|
2209
2232
|
}
|
|
2210
2233
|
parsed.job.scheduleMode = normalized.scheduleMode;
|
|
@@ -2243,13 +2266,13 @@ function createLoopRegistration(options = {}) {
|
|
|
2243
2266
|
job: parsed.job.name || parsed.job.id,
|
|
2244
2267
|
goal: dedicatedGoal.id
|
|
2245
2268
|
});
|
|
2246
|
-
await
|
|
2269
|
+
await toast(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");
|
|
2247
2270
|
return;
|
|
2248
2271
|
}
|
|
2249
2272
|
}
|
|
2250
2273
|
if (parsed.job.dryRun) {
|
|
2251
|
-
await
|
|
2252
|
-
await
|
|
2274
|
+
await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
|
|
2275
|
+
await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
|
|
2253
2276
|
return;
|
|
2254
2277
|
}
|
|
2255
2278
|
const state = await readState2(directory, sessionID);
|
|
@@ -2273,7 +2296,7 @@ function createLoopRegistration(options = {}) {
|
|
|
2273
2296
|
await scheduleDueWork(directory, client, sessionID);
|
|
2274
2297
|
if (parsed.job.immediate)
|
|
2275
2298
|
scheduleIdleWork(directory, client, sessionID);
|
|
2276
|
-
await
|
|
2299
|
+
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
|
|
2277
2300
|
await appendLoopLog2(directory, replaced ? "replace" : "add", {
|
|
2278
2301
|
sessionID,
|
|
2279
2302
|
job: parsed.job.name || parsed.job.id,
|
|
@@ -2400,19 +2423,19 @@ function clearSessionActivity(sessionID) {
|
|
|
2400
2423
|
// src/source/runtime/scheduler-diagnostics.js
|
|
2401
2424
|
var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000;
|
|
2402
2425
|
function createSchedulerDiagnostics(options = {}) {
|
|
2403
|
-
const
|
|
2404
|
-
const
|
|
2426
|
+
const now = typeof options.now === "function" ? options.now : Date.now;
|
|
2427
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
|
|
2405
2428
|
const configured = Number(options.throttleMs);
|
|
2406
2429
|
const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS;
|
|
2407
2430
|
const lastLogged = new Map;
|
|
2408
2431
|
async function logDeferral(directory, sessionID, reason, extra = {}) {
|
|
2409
2432
|
const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`;
|
|
2410
|
-
const current =
|
|
2433
|
+
const current = now();
|
|
2411
2434
|
const previous = Number(lastLogged.get(key) || 0);
|
|
2412
2435
|
if (previous > 0 && current - previous < throttleMs)
|
|
2413
2436
|
return false;
|
|
2414
2437
|
lastLogged.set(key, current);
|
|
2415
|
-
await
|
|
2438
|
+
await appendLoopLog(directory, "deferred", {
|
|
2416
2439
|
sessionID,
|
|
2417
2440
|
reason,
|
|
2418
2441
|
...extra
|
|
@@ -2458,7 +2481,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2458
2481
|
if (options.appendLoopLog)
|
|
2459
2482
|
await options.appendLoopLog(directory, event, extra);
|
|
2460
2483
|
};
|
|
2461
|
-
const
|
|
2484
|
+
const toast = async (client, message, level) => {
|
|
2462
2485
|
if (options.toast)
|
|
2463
2486
|
await options.toast(client, message, level);
|
|
2464
2487
|
};
|
|
@@ -2522,7 +2545,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2522
2545
|
}
|
|
2523
2546
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2524
2547
|
}).catch((error) => {
|
|
2525
|
-
|
|
2548
|
+
toast(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {});
|
|
2526
2549
|
appendLog(directory, "idle-error", { sessionID, error: errorMessage(error) }).catch(() => {});
|
|
2527
2550
|
});
|
|
2528
2551
|
}, idleDebounceMs);
|
|
@@ -2534,13 +2557,13 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2534
2557
|
const timer = setIntervalFn(() => {
|
|
2535
2558
|
Promise.resolve().then(async () => {
|
|
2536
2559
|
const state = await readStateFn(directory, sessionID);
|
|
2537
|
-
const
|
|
2560
|
+
const delay = nextDueDelay(state, clock());
|
|
2538
2561
|
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)));
|
|
2539
|
-
if (!hasJobs || !Number.isFinite(
|
|
2562
|
+
if (!hasJobs || !Number.isFinite(delay)) {
|
|
2540
2563
|
stopWatchdog(sessionID);
|
|
2541
2564
|
return;
|
|
2542
2565
|
}
|
|
2543
|
-
if (
|
|
2566
|
+
if (delay <= 0)
|
|
2544
2567
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2545
2568
|
else
|
|
2546
2569
|
await scheduleDueWork(directory, client, sessionID);
|
|
@@ -2557,10 +2580,10 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2557
2580
|
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
2558
2581
|
cancelDueWork(sessionID);
|
|
2559
2582
|
const state = await readStateFn(directory, sessionID);
|
|
2560
|
-
const
|
|
2561
|
-
if (!Number.isFinite(
|
|
2583
|
+
const delay = nextDueDelay(state, clock());
|
|
2584
|
+
if (!Number.isFinite(delay))
|
|
2562
2585
|
return;
|
|
2563
|
-
const wait = Math.min(Math.max(
|
|
2586
|
+
const wait = Math.min(Math.max(delay, minDelayMs, minDueTimerMs), maxDueTimerMs);
|
|
2564
2587
|
const timer = setTimeoutFn(() => {
|
|
2565
2588
|
dueTimers.delete(sessionID);
|
|
2566
2589
|
Promise.resolve().then(async () => {
|
|
@@ -2577,7 +2600,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2577
2600
|
}
|
|
2578
2601
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2579
2602
|
}).catch((error) => {
|
|
2580
|
-
|
|
2603
|
+
toast(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {});
|
|
2581
2604
|
appendLog(directory, "due-timer-error", { sessionID, error: errorMessage(error) }).catch(() => {});
|
|
2582
2605
|
});
|
|
2583
2606
|
}, wait);
|
|
@@ -2616,9 +2639,9 @@ function requireFunction5(value, name) {
|
|
|
2616
2639
|
return value;
|
|
2617
2640
|
}
|
|
2618
2641
|
function createGoalExecutionPolicy(options = {}) {
|
|
2619
|
-
const
|
|
2642
|
+
const runShellCommand = requireFunction5(options.runShellCommand, "runShellCommand");
|
|
2620
2643
|
const dangerousShell = requireFunction5(options.dangerousShell, "dangerousShell");
|
|
2621
|
-
const
|
|
2644
|
+
const toast = requireFunction5(options.toast, "toast");
|
|
2622
2645
|
const now2 = typeof options.now === "function" ? options.now : now;
|
|
2623
2646
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2624
2647
|
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
@@ -2639,7 +2662,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2639
2662
|
job.paused = true;
|
|
2640
2663
|
job.goalNoProgressPausedAt = now2();
|
|
2641
2664
|
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`;
|
|
2642
|
-
await
|
|
2665
|
+
await toast(client, job.goalNoProgressReason, "warning");
|
|
2643
2666
|
await appendLoopLog2(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit });
|
|
2644
2667
|
}
|
|
2645
2668
|
return job;
|
|
@@ -2653,7 +2676,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2653
2676
|
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." });
|
|
2654
2677
|
continue;
|
|
2655
2678
|
}
|
|
2656
|
-
const result = await
|
|
2679
|
+
const result = await runShellCommand(command, directory, job.timeoutMs || 300000);
|
|
2657
2680
|
results.push({ command, code: result.code, output: (result.stdout + `
|
|
2658
2681
|
` + result.stderr).slice(0, 1200) });
|
|
2659
2682
|
}
|
|
@@ -2663,7 +2686,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2663
2686
|
if (allPassed) {
|
|
2664
2687
|
job.goalChecksPassedAt = now2();
|
|
2665
2688
|
job.failureCount = 0;
|
|
2666
|
-
await
|
|
2689
|
+
await toast(client, "Goal checks passed.", "success");
|
|
2667
2690
|
if (job.goalCompleteWhenChecksPass) {
|
|
2668
2691
|
job.goalStatus = "completed";
|
|
2669
2692
|
job.enabled = false;
|
|
@@ -2681,7 +2704,7 @@ exit=${item.code}
|
|
|
2681
2704
|
${item.output}`).join(`
|
|
2682
2705
|
|
|
2683
2706
|
`).slice(0, 4000);
|
|
2684
|
-
await
|
|
2707
|
+
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
|
|
2685
2708
|
}
|
|
2686
2709
|
await appendLoopLog2(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) });
|
|
2687
2710
|
return job;
|
|
@@ -2716,7 +2739,7 @@ function dangerousShell(command) {
|
|
|
2716
2739
|
].some((pattern) => pattern.test(text));
|
|
2717
2740
|
}
|
|
2718
2741
|
function createJobWorkspaceRuntime(options = {}) {
|
|
2719
|
-
const
|
|
2742
|
+
const toast = requireFunction6(options.toast, "toast");
|
|
2720
2743
|
const runProcess2 = typeof options.runProcess === "function" ? options.runProcess : runProcess;
|
|
2721
2744
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2722
2745
|
const readSmallTextFile2 = typeof options.readSmallTextFile === "function" ? options.readSmallTextFile : readSmallTextFile;
|
|
@@ -2760,7 +2783,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2760
2783
|
if (result.code !== 0)
|
|
2761
2784
|
result = await runProcess2("git", ["switch", "-c", branch], directory, 30000);
|
|
2762
2785
|
job.branchDone = true;
|
|
2763
|
-
await
|
|
2786
|
+
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
|
|
2764
2787
|
await appendLoopLog2(directory, "branch", { sessionID, branch, code: result.code });
|
|
2765
2788
|
return job;
|
|
2766
2789
|
}
|
|
@@ -2854,7 +2877,7 @@ ${staged.stdout}`);
|
|
|
2854
2877
|
await runProcess2("git", ["add", "-A"], directory, 120000);
|
|
2855
2878
|
await runProcess2("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120000);
|
|
2856
2879
|
}
|
|
2857
|
-
await
|
|
2880
|
+
await toast(client, `Loop checkpoint saved: ${prefix}`, "success");
|
|
2858
2881
|
}
|
|
2859
2882
|
return {
|
|
2860
2883
|
buildPrompt,
|
|
@@ -2968,15 +2991,15 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2968
2991
|
}
|
|
2969
2992
|
return attempted ? { type: "unknown", source: "sdk-error" } : undefined;
|
|
2970
2993
|
}
|
|
2971
|
-
async function canFinalizeActiveRun(directory, client, sessionID, active,
|
|
2994
|
+
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
2972
2995
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
|
|
2973
2996
|
return false;
|
|
2974
|
-
if (!
|
|
2997
|
+
if (!options.requireIdle && !options.forceStale)
|
|
2975
2998
|
return true;
|
|
2976
|
-
const completion =
|
|
2999
|
+
const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
|
|
2977
3000
|
if (settledAssistantCompletion(completion))
|
|
2978
3001
|
return true;
|
|
2979
|
-
if (!
|
|
3002
|
+
if (!options.requireIdle)
|
|
2980
3003
|
return completion === "unknown" && staleActiveRun(sessionID);
|
|
2981
3004
|
const cached = sessionStatuses.get(sessionID);
|
|
2982
3005
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
@@ -2988,11 +3011,11 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2988
3011
|
return true;
|
|
2989
3012
|
}
|
|
2990
3013
|
if (live?.type) {
|
|
2991
|
-
if (live.type === "busy" &&
|
|
3014
|
+
if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2992
3015
|
return true;
|
|
2993
3016
|
return false;
|
|
2994
3017
|
}
|
|
2995
|
-
if (
|
|
3018
|
+
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2996
3019
|
return true;
|
|
2997
3020
|
return cachedIdleAfterRun;
|
|
2998
3021
|
}
|
|
@@ -3015,7 +3038,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3015
3038
|
});
|
|
3016
3039
|
return true;
|
|
3017
3040
|
}
|
|
3018
|
-
async function sessionStatusType(client, sessionID, directory,
|
|
3041
|
+
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
3019
3042
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
3020
3043
|
markSessionStatus(sessionID, "busy");
|
|
3021
3044
|
return "busy";
|
|
@@ -3035,7 +3058,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3035
3058
|
if (live?.type) {
|
|
3036
3059
|
if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
|
|
3037
3060
|
return "idle";
|
|
3038
|
-
if ((live.type === "busy" || live.type === "retry") &&
|
|
3061
|
+
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
3039
3062
|
const active = activeRuns.get(sessionID);
|
|
3040
3063
|
if (active) {
|
|
3041
3064
|
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
|
|
@@ -3048,7 +3071,11 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3048
3071
|
...completion === "completed" ? {} : { staleStatus: live.type }
|
|
3049
3072
|
};
|
|
3050
3073
|
const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
|
|
3051
|
-
|
|
3074
|
+
active.statusRecoveryLogEvents ??= new Set;
|
|
3075
|
+
if (!active.statusRecoveryLogEvents.has(recoveryEvent)) {
|
|
3076
|
+
active.statusRecoveryLogEvents.add(recoveryEvent);
|
|
3077
|
+
await appendLoopLog2(directory, recoveryEvent, logDetails);
|
|
3078
|
+
}
|
|
3052
3079
|
return "idle";
|
|
3053
3080
|
}
|
|
3054
3081
|
}
|
|
@@ -3060,8 +3087,8 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3060
3087
|
markSessionStatus(sessionID, fallback);
|
|
3061
3088
|
return fallback;
|
|
3062
3089
|
}
|
|
3063
|
-
async function sessionIsIdle(client, sessionID, directory,
|
|
3064
|
-
return await sessionStatusType(client, sessionID, directory,
|
|
3090
|
+
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
3091
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle";
|
|
3065
3092
|
}
|
|
3066
3093
|
return {
|
|
3067
3094
|
markSessionStatus,
|
|
@@ -3247,8 +3274,8 @@ function createActionDispatcher(options = {}) {
|
|
|
3247
3274
|
const shellBody = { command, agent };
|
|
3248
3275
|
if (model)
|
|
3249
3276
|
shellBody.model = model;
|
|
3250
|
-
const
|
|
3251
|
-
return { startsAssistantTurn: true, dispatch
|
|
3277
|
+
const dispatch = fireSdk2(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
|
|
3278
|
+
return { startsAssistantTurn: true, dispatch };
|
|
3252
3279
|
}
|
|
3253
3280
|
const prompt = await buildPrompt(directory, job);
|
|
3254
3281
|
const prefix = kind === "goal" ? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real." : "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly.";
|
|
@@ -3280,8 +3307,10 @@ function messageText(message) {
|
|
|
3280
3307
|
return "";
|
|
3281
3308
|
}
|
|
3282
3309
|
async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
|
|
3283
|
-
|
|
3284
|
-
|
|
3310
|
+
const completionBounded = isCompletionBoundedContinuation(job?.action);
|
|
3311
|
+
const continuation = completionBounded || isContinuationShorthand(job?.action);
|
|
3312
|
+
if (job?.scheduleMode !== "idle" || !continuation) {
|
|
3313
|
+
return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false };
|
|
3285
3314
|
}
|
|
3286
3315
|
const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
|
|
3287
3316
|
if (!messages)
|
|
@@ -3299,11 +3328,31 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
|
|
|
3299
3328
|
return { job, terminal: false, pausedNow: false };
|
|
3300
3329
|
}
|
|
3301
3330
|
const text = messageText(tail);
|
|
3331
|
+
const waitingUser = isWaitingUserReply(text);
|
|
3332
|
+
if (waitingUser) {
|
|
3333
|
+
if (job.terminalNoWorkCount)
|
|
3334
|
+
job.terminalNoWorkCount = 0;
|
|
3335
|
+
job.waitingUserCount = (job.waitingUserCount || 0) + 1;
|
|
3336
|
+
job.lastWaitingUserAt = Date.now();
|
|
3337
|
+
job.lastWaitingUserSummary = text.slice(0, 1000);
|
|
3338
|
+
const threshold = Math.max(2, Number(options.waitingUserThreshold) || 2);
|
|
3339
|
+
const waitingUserPausedNow = job.waitingUserCount >= threshold && !job.paused;
|
|
3340
|
+
if (waitingUserPausedNow) {
|
|
3341
|
+
job.paused = true;
|
|
3342
|
+
job.lastFailureReason = "waiting_user";
|
|
3343
|
+
}
|
|
3344
|
+
return { job, terminal: false, pausedNow: false, waitingUser: true, waitingUserPausedNow, text };
|
|
3345
|
+
}
|
|
3346
|
+
if (job.waitingUserCount)
|
|
3347
|
+
job.waitingUserCount = 0;
|
|
3348
|
+
if (!completionBounded) {
|
|
3349
|
+
return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
|
|
3350
|
+
}
|
|
3302
3351
|
const terminal = isTerminalNoWorkReply(text);
|
|
3303
3352
|
if (!terminal) {
|
|
3304
3353
|
if (job.terminalNoWorkCount)
|
|
3305
3354
|
job.terminalNoWorkCount = 0;
|
|
3306
|
-
return { job, terminal: false, pausedNow: false, text };
|
|
3355
|
+
return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
|
|
3307
3356
|
}
|
|
3308
3357
|
job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
|
|
3309
3358
|
job.lastTerminalNoWorkAt = Date.now();
|
|
@@ -3314,7 +3363,7 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
|
|
|
3314
3363
|
job.paused = true;
|
|
3315
3364
|
job.lastFailureReason = "terminal_no_work";
|
|
3316
3365
|
}
|
|
3317
|
-
return { job, terminal: true, pausedNow, text };
|
|
3366
|
+
return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
|
|
3318
3367
|
}
|
|
3319
3368
|
|
|
3320
3369
|
// src/source/runtime/run-finalization.js
|
|
@@ -3408,6 +3457,16 @@ exit=` + postrun.code + `
|
|
|
3408
3457
|
}
|
|
3409
3458
|
const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
|
|
3410
3459
|
job = terminal.job;
|
|
3460
|
+
if (terminal.waitingUserPausedNow) {
|
|
3461
|
+
await appendLoopLog2(directory, "waiting-user", {
|
|
3462
|
+
sessionID,
|
|
3463
|
+
job: job.name || job.id,
|
|
3464
|
+
count: job.waitingUserCount,
|
|
3465
|
+
summary: String(terminal.text || "").slice(0, 1000)
|
|
3466
|
+
});
|
|
3467
|
+
await notifyJob2(directory, job, "waiting_user");
|
|
3468
|
+
await toast2(client, "Loop paused: autonomous continuation is waiting for explicit user approval, access, or action.", "warning");
|
|
3469
|
+
}
|
|
3411
3470
|
if (terminal.pausedNow) {
|
|
3412
3471
|
await appendLoopLog2(directory, "terminal-no-work", {
|
|
3413
3472
|
sessionID,
|
|
@@ -3615,6 +3674,25 @@ function requireFunction10(value, label) {
|
|
|
3615
3674
|
throw new TypeError(`createLoopExecutor requires ${label}`);
|
|
3616
3675
|
return value;
|
|
3617
3676
|
}
|
|
3677
|
+
function sessionErrorDetails(event, fallbackMessage) {
|
|
3678
|
+
if (event?.type !== "session.error")
|
|
3679
|
+
return;
|
|
3680
|
+
const sessionID = event?.properties?.sessionID;
|
|
3681
|
+
if (typeof sessionID !== "string" || !sessionID)
|
|
3682
|
+
return;
|
|
3683
|
+
const error = event?.properties?.error;
|
|
3684
|
+
const name = error && typeof error === "object" && typeof error.name === "string" ? error.name : "SessionError";
|
|
3685
|
+
const data = error && typeof error === "object" && error.data && typeof error.data === "object" ? error.data : undefined;
|
|
3686
|
+
const message = typeof data?.message === "string" && data.message.trim() ? data.message.trim() : fallbackMessage(error);
|
|
3687
|
+
return {
|
|
3688
|
+
sessionID,
|
|
3689
|
+
error,
|
|
3690
|
+
name,
|
|
3691
|
+
message,
|
|
3692
|
+
aborted: name === "MessageAbortedError",
|
|
3693
|
+
retryable: name === "APIError" && data?.isRetryable === true
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3618
3696
|
function createLoopExecutor(options = {}) {
|
|
3619
3697
|
const workspace = options.workspace || {};
|
|
3620
3698
|
const goalPolicy = options.goalPolicy || {};
|
|
@@ -3711,7 +3789,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3711
3789
|
toast: toast2,
|
|
3712
3790
|
dangerousShell: dangerousShell2
|
|
3713
3791
|
});
|
|
3714
|
-
const
|
|
3792
|
+
const dueJobs = admissionRuntime.dueJobs;
|
|
3715
3793
|
function clearActiveRun(sessionID) {
|
|
3716
3794
|
const active = activeRuns.get(sessionID);
|
|
3717
3795
|
if (active?.timer)
|
|
@@ -3793,6 +3871,56 @@ function createLoopExecutor(options = {}) {
|
|
|
3793
3871
|
await scheduleDueWork(directory, client, sessionID, retryDelay);
|
|
3794
3872
|
return true;
|
|
3795
3873
|
}
|
|
3874
|
+
async function handleSessionError(directory, client, event) {
|
|
3875
|
+
const details = sessionErrorDetails(event, errorMessage);
|
|
3876
|
+
if (!details || details.aborted)
|
|
3877
|
+
return false;
|
|
3878
|
+
const active = activeRuns.get(details.sessionID);
|
|
3879
|
+
if (!active || active.compactionOnly)
|
|
3880
|
+
return false;
|
|
3881
|
+
const snapshot = active;
|
|
3882
|
+
clearActiveRun(details.sessionID);
|
|
3883
|
+
clearSessionStatus(details.sessionID);
|
|
3884
|
+
if (details.retryable) {
|
|
3885
|
+
const refunded = await persistInfrastructureRefund(directory, details.sessionID, snapshot, {
|
|
3886
|
+
reason: "session_error_retryable",
|
|
3887
|
+
error: details.message
|
|
3888
|
+
});
|
|
3889
|
+
await appendLoopLog2(directory, "provider-session-error-retry", {
|
|
3890
|
+
sessionID: details.sessionID,
|
|
3891
|
+
job: refunded.job?.name || snapshot.jobId,
|
|
3892
|
+
errorName: details.name,
|
|
3893
|
+
error: details.message,
|
|
3894
|
+
retryInMs: refunded.delayMs
|
|
3895
|
+
});
|
|
3896
|
+
await toast2(client, `Loop provider turn failed after OpenCode retries; the logical run was refunded and will retry with backoff: ${details.message}`, "warning");
|
|
3897
|
+
await scheduleDueWork(directory, client, details.sessionID, refunded.delayMs);
|
|
3898
|
+
return true;
|
|
3899
|
+
}
|
|
3900
|
+
const state = await readState2(directory, details.sessionID);
|
|
3901
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === snapshot.jobId);
|
|
3902
|
+
if (job) {
|
|
3903
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
3904
|
+
job.paused = true;
|
|
3905
|
+
job.lastFailureReason = "session_error";
|
|
3906
|
+
job.lastSessionErrorName = details.name;
|
|
3907
|
+
job.lastSessionError = details.message.slice(0, 4000);
|
|
3908
|
+
job.lastSessionErrorAt = now2();
|
|
3909
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3910
|
+
await writeState2(directory, details.sessionID, state);
|
|
3911
|
+
await notifyJob2(directory, job, "session_error");
|
|
3912
|
+
}
|
|
3913
|
+
await appendLoopLog2(directory, "session-error", {
|
|
3914
|
+
sessionID: details.sessionID,
|
|
3915
|
+
job: job?.name || snapshot.jobId,
|
|
3916
|
+
errorName: details.name,
|
|
3917
|
+
error: details.message,
|
|
3918
|
+
paused: Boolean(job?.paused)
|
|
3919
|
+
});
|
|
3920
|
+
await toast2(client, `Loop paused after a terminal OpenCode session error: ${details.message}`, "error");
|
|
3921
|
+
await scheduleDueWork(directory, client, details.sessionID);
|
|
3922
|
+
return true;
|
|
3923
|
+
}
|
|
3796
3924
|
async function recoverStuckProviderRetry(directory, client, sessionID) {
|
|
3797
3925
|
const active = activeRuns.get(sessionID);
|
|
3798
3926
|
if (!active || active.compactionOnly) {
|
|
@@ -3934,7 +4062,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3934
4062
|
candidate.watchTriggered = true;
|
|
3935
4063
|
}
|
|
3936
4064
|
}
|
|
3937
|
-
const due =
|
|
4065
|
+
const due = dueJobs(state, Boolean(runOptions.force));
|
|
3938
4066
|
if (!due.length) {
|
|
3939
4067
|
await writeState2(directory, sessionID, state);
|
|
3940
4068
|
await reschedule();
|
|
@@ -4063,10 +4191,11 @@ function createLoopExecutor(options = {}) {
|
|
|
4063
4191
|
}
|
|
4064
4192
|
}
|
|
4065
4193
|
return {
|
|
4066
|
-
dueJobs
|
|
4194
|
+
dueJobs,
|
|
4067
4195
|
clearActiveRun,
|
|
4068
4196
|
disposeSession,
|
|
4069
4197
|
recoverActiveDispatchFailure,
|
|
4198
|
+
handleSessionError,
|
|
4070
4199
|
recoverStuckProviderRetry,
|
|
4071
4200
|
finalizeActiveRun,
|
|
4072
4201
|
fireAction,
|
|
@@ -4288,6 +4417,7 @@ var executorRuntime = createLoopExecutor({
|
|
|
4288
4417
|
});
|
|
4289
4418
|
var {
|
|
4290
4419
|
clearActiveRun,
|
|
4420
|
+
handleSessionError,
|
|
4291
4421
|
finalizeActiveRun,
|
|
4292
4422
|
maybeRunDueJobs: runDueJobs,
|
|
4293
4423
|
sessionIsIdle,
|
|
@@ -4400,47 +4530,73 @@ function steeringToolRejection(sessionID) {
|
|
|
4400
4530
|
output: "Goal lifecycle update deferred because queued user steering is pending. The experimental Goal remains active and unchanged."
|
|
4401
4531
|
};
|
|
4402
4532
|
}
|
|
4533
|
+
async function dedicatedGoalToolRejection(directory, sessionID) {
|
|
4534
|
+
const goal = await findDedicatedGoalForSession(directory, sessionID);
|
|
4535
|
+
if (!goal || goal.status === "completed")
|
|
4536
|
+
return;
|
|
4537
|
+
return {
|
|
4538
|
+
title: "Dedicated Goal owns lifecycle",
|
|
4539
|
+
output: [
|
|
4540
|
+
`A dedicated OpenCode Goal is persisted for this session (status=${String(goal.status || "unknown")}).`,
|
|
4541
|
+
"The opencode_loop_goal_* tools control only experimental /loop-goal jobs and will not mutate the dedicated Goal.",
|
|
4542
|
+
"Use opencode_goal_progress for checkpoints, opencode_goal_wait_for_user when user/manual input is required, opencode_goal_blocked for a genuine blocker, or opencode_goal_complete for verified completion."
|
|
4543
|
+
].join(`
|
|
4544
|
+
`)
|
|
4545
|
+
};
|
|
4546
|
+
}
|
|
4403
4547
|
function goalTools(defaultDirectory) {
|
|
4404
4548
|
return {
|
|
4405
4549
|
opencode_loop_goal_complete: tool({
|
|
4406
|
-
description: "
|
|
4550
|
+
description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as completed. Never use this tool to control a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_complete there. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
4407
4551
|
args: {
|
|
4408
4552
|
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
4409
4553
|
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results.")
|
|
4410
4554
|
},
|
|
4411
4555
|
execute: async (args, context) => {
|
|
4556
|
+
const directory = context.directory || defaultDirectory;
|
|
4557
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4558
|
+
if (dedicated)
|
|
4559
|
+
return dedicated;
|
|
4412
4560
|
const steering = steeringToolRejection(context.sessionID);
|
|
4413
4561
|
if (steering)
|
|
4414
4562
|
return steering;
|
|
4415
|
-
const result = await setGoalComplete(
|
|
4563
|
+
const result = await setGoalComplete(directory, context.sessionID, args);
|
|
4416
4564
|
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message };
|
|
4417
4565
|
}
|
|
4418
4566
|
}),
|
|
4419
4567
|
opencode_loop_goal_blocked: tool({
|
|
4420
|
-
description: "
|
|
4568
|
+
description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as blocked. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_wait_for_user for user/manual input or opencode_goal_blocked for a genuine dedicated-Goal blocker.",
|
|
4421
4569
|
args: {
|
|
4422
4570
|
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
4423
4571
|
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue.")
|
|
4424
4572
|
},
|
|
4425
4573
|
execute: async (args, context) => {
|
|
4574
|
+
const directory = context.directory || defaultDirectory;
|
|
4575
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4576
|
+
if (dedicated)
|
|
4577
|
+
return dedicated;
|
|
4426
4578
|
const steering = steeringToolRejection(context.sessionID);
|
|
4427
4579
|
if (steering)
|
|
4428
4580
|
return steering;
|
|
4429
|
-
const result = await setGoalBlocked(
|
|
4581
|
+
const result = await setGoalBlocked(directory, context.sessionID, args);
|
|
4430
4582
|
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message };
|
|
4431
4583
|
}
|
|
4432
4584
|
}),
|
|
4433
4585
|
opencode_loop_goal_progress: tool({
|
|
4434
|
-
description: "
|
|
4586
|
+
description: "Experimental /loop-goal ONLY: record meaningful progress on the current OpenCode Loop experimental goal. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_progress there.",
|
|
4435
4587
|
args: {
|
|
4436
4588
|
summary: tool.schema.string().describe("What useful progress was made."),
|
|
4437
4589
|
next: tool.schema.string().describe("The next step toward completing the goal.")
|
|
4438
4590
|
},
|
|
4439
4591
|
execute: async (args, context) => {
|
|
4592
|
+
const directory = context.directory || defaultDirectory;
|
|
4593
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4594
|
+
if (dedicated)
|
|
4595
|
+
return dedicated;
|
|
4440
4596
|
const steering = steeringToolRejection(context.sessionID);
|
|
4441
4597
|
if (steering)
|
|
4442
4598
|
return steering;
|
|
4443
|
-
const result = await setGoalProgress(
|
|
4599
|
+
const result = await setGoalProgress(directory, context.sessionID, args);
|
|
4444
4600
|
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message };
|
|
4445
4601
|
}
|
|
4446
4602
|
})
|
|
@@ -4491,6 +4647,11 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
4491
4647
|
const steering = await goalSteeringRuntime.handleEvent(directory, client, event);
|
|
4492
4648
|
if (steering?.handled && steering.sessionID)
|
|
4493
4649
|
rememberSession(directory, client, steering.sessionID);
|
|
4650
|
+
if (event.type === "session.error") {
|
|
4651
|
+
const handledError = await handleSessionError(directory, client, event);
|
|
4652
|
+
if (handledError && event?.properties?.sessionID)
|
|
4653
|
+
rememberSession(directory, client, event.properties.sessionID);
|
|
4654
|
+
}
|
|
4494
4655
|
const statusUpdate = updateSessionStatusFromEvent(event);
|
|
4495
4656
|
if (statusUpdate?.sessionID)
|
|
4496
4657
|
rememberSession(directory, client, statusUpdate.sessionID);
|
|
@@ -4507,8 +4668,8 @@ var DEFAULT_SESSION_STALE_MS = 12 * 60 * 60 * 1000;
|
|
|
4507
4668
|
function sessionKey(sessionID) {
|
|
4508
4669
|
return String(sessionID || "").trim();
|
|
4509
4670
|
}
|
|
4510
|
-
function createSessionRegistry({ now
|
|
4511
|
-
if (typeof
|
|
4671
|
+
function createSessionRegistry({ now = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
|
|
4672
|
+
if (typeof now !== "function")
|
|
4512
4673
|
throw new TypeError("session registry requires a clock function");
|
|
4513
4674
|
if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0)
|
|
4514
4675
|
throw new TypeError("session registry requires a non-negative staleAfterMs");
|
|
@@ -4517,7 +4678,7 @@ function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SE
|
|
|
4517
4678
|
const key = sessionKey(sessionID);
|
|
4518
4679
|
if (!key)
|
|
4519
4680
|
throw new TypeError("session registry requires a session ID");
|
|
4520
|
-
const seenAt = Number(
|
|
4681
|
+
const seenAt = Number(now());
|
|
4521
4682
|
if (!Number.isFinite(seenAt))
|
|
4522
4683
|
throw new TypeError("session registry clock must return a finite number");
|
|
4523
4684
|
const entry = Object.freeze({ sessionID: key, runtime, seenAt });
|
|
@@ -4536,7 +4697,7 @@ function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SE
|
|
|
4536
4697
|
return false;
|
|
4537
4698
|
return sessions.delete(key);
|
|
4538
4699
|
}
|
|
4539
|
-
function pruneStale(at =
|
|
4700
|
+
function pruneStale(at = now()) {
|
|
4540
4701
|
const timestamp = Number(at);
|
|
4541
4702
|
if (!Number.isFinite(timestamp))
|
|
4542
4703
|
throw new TypeError("session registry prune time must be finite");
|
|
@@ -4620,7 +4781,7 @@ function createRuntimeScope() {
|
|
|
4620
4781
|
}
|
|
4621
4782
|
|
|
4622
4783
|
// src/source/runtime/timers.js
|
|
4623
|
-
function createOwnedTimer(scope, callback,
|
|
4784
|
+
function createOwnedTimer(scope, callback, delay, repeat, api, ref) {
|
|
4624
4785
|
if (!scope?.isActive?.())
|
|
4625
4786
|
return;
|
|
4626
4787
|
let active = true;
|
|
@@ -4635,7 +4796,7 @@ function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
|
|
|
4635
4796
|
}
|
|
4636
4797
|
callback(...args);
|
|
4637
4798
|
});
|
|
4638
|
-
handle = repeat ? api.setInterval(invoke,
|
|
4799
|
+
handle = repeat ? api.setInterval(invoke, delay) : api.setTimeout(invoke, delay);
|
|
4639
4800
|
release = scope.track(() => {
|
|
4640
4801
|
if (repeat)
|
|
4641
4802
|
api.clearInterval(handle);
|
|
@@ -4661,11 +4822,11 @@ function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
|
|
|
4661
4822
|
}
|
|
4662
4823
|
function createRuntimeTimers(scope, api = globalThis) {
|
|
4663
4824
|
return Object.freeze({
|
|
4664
|
-
timeout(callback,
|
|
4665
|
-
return createOwnedTimer(scope, callback,
|
|
4825
|
+
timeout(callback, delay, options = {}) {
|
|
4826
|
+
return createOwnedTimer(scope, callback, delay, false, api, options.ref !== false);
|
|
4666
4827
|
},
|
|
4667
|
-
interval(callback,
|
|
4668
|
-
return createOwnedTimer(scope, callback,
|
|
4828
|
+
interval(callback, delay, options = {}) {
|
|
4829
|
+
return createOwnedTimer(scope, callback, delay, true, api, options.ref !== false);
|
|
4669
4830
|
}
|
|
4670
4831
|
});
|
|
4671
4832
|
}
|
|
@@ -4690,14 +4851,14 @@ function validateRuntime(runtime, sessionID) {
|
|
|
4690
4851
|
return runtime;
|
|
4691
4852
|
}
|
|
4692
4853
|
function createSessionRuntimeManager({
|
|
4693
|
-
now
|
|
4854
|
+
now = Date.now,
|
|
4694
4855
|
staleAfterMs = DEFAULT_SESSION_STALE_MS,
|
|
4695
4856
|
timerAPI = globalThis,
|
|
4696
4857
|
runtimeFactory = defaultRuntimeFactory
|
|
4697
4858
|
} = {}) {
|
|
4698
4859
|
if (typeof runtimeFactory !== "function")
|
|
4699
4860
|
throw new TypeError("session runtime manager requires a runtime factory");
|
|
4700
|
-
const registry = createSessionRegistry({ now
|
|
4861
|
+
const registry = createSessionRegistry({ now, staleAfterMs });
|
|
4701
4862
|
let disposed = false;
|
|
4702
4863
|
function observeExternal(sessionID) {
|
|
4703
4864
|
if (disposed)
|
|
@@ -4727,7 +4888,7 @@ function createSessionRuntimeManager({
|
|
|
4727
4888
|
current.runtime.dispose(reason);
|
|
4728
4889
|
return true;
|
|
4729
4890
|
}
|
|
4730
|
-
function pruneStale(at =
|
|
4891
|
+
function pruneStale(at = now()) {
|
|
4731
4892
|
const before = new Map(registry.entries().map((entry) => [entry.sessionID, entry.runtime]));
|
|
4732
4893
|
const removed = registry.pruneStale(at);
|
|
4733
4894
|
const errors = [];
|
|
@@ -4928,12 +5089,12 @@ function scopedClient(client) {
|
|
|
4928
5089
|
});
|
|
4929
5090
|
}
|
|
4930
5091
|
function scheduleLegacyCleanup(dispose, isCurrentGeneration) {
|
|
4931
|
-
for (const
|
|
5092
|
+
for (const delay of [0, 25, 100, 500, 2000, 1e4, 30000]) {
|
|
4932
5093
|
const timer = setTimeout(() => {
|
|
4933
5094
|
if (!isCurrentGeneration())
|
|
4934
5095
|
return;
|
|
4935
5096
|
Promise.resolve(dispose()).catch(() => {});
|
|
4936
|
-
},
|
|
5097
|
+
}, delay);
|
|
4937
5098
|
timer.unref?.();
|
|
4938
5099
|
}
|
|
4939
5100
|
}
|