@bybrawe/opencode-loop 0.5.35 → 0.5.37
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 +22 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/scripts/empty-turn-recovery-test.mjs +150 -0
- package/scripts/host-adapter-contract-test.mjs +2 -2
- package/scripts/host-goal-coexistence-canary.mjs +419 -0
- package/scripts/smoke-test.mjs +21 -0
- package/src/index.js +222 -97
- package/src/source/legacy-v1.js +29 -6
- package/src/source/opencode/host.js +23 -2
- package/src/source/runtime/empty-turn.js +46 -0
- package/src/source/runtime/executor.js +28 -0
- package/src/source/runtime/session-status.js +14 -8
package/src/index.js
CHANGED
|
@@ -801,11 +801,25 @@ function orderedSessionMessages(messages) {
|
|
|
801
801
|
return { message, index, created: Number.isFinite(created) ? created : 0 };
|
|
802
802
|
}).sort((a, b) => a.created - b.created || a.index - b.index).map((entry) => entry.message);
|
|
803
803
|
}
|
|
804
|
+
function assistantMessageHasMeaningfulActivity(message) {
|
|
805
|
+
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
806
|
+
for (const part of parts) {
|
|
807
|
+
if (!part || typeof part !== "object")
|
|
808
|
+
continue;
|
|
809
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim())
|
|
810
|
+
return true;
|
|
811
|
+
if (["tool", "file", "patch", "artifact"].includes(String(part.type || "")))
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
const info = message?.info || message || {};
|
|
815
|
+
return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim());
|
|
816
|
+
}
|
|
804
817
|
async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
|
|
805
818
|
const messages = await readRecentSessionMessages(client, sessionID, directory);
|
|
806
819
|
if (!messages)
|
|
807
820
|
return "unknown";
|
|
808
|
-
const
|
|
821
|
+
const ordered = orderedSessionMessages(messages);
|
|
822
|
+
const tail = ordered.at(-1);
|
|
809
823
|
const info = tail?.info || tail;
|
|
810
824
|
if (!info || info.role !== "assistant")
|
|
811
825
|
return "incomplete";
|
|
@@ -816,7 +830,17 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act
|
|
|
816
830
|
const startedAt = Number(active?.startedAt || 0);
|
|
817
831
|
if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt))
|
|
818
832
|
return "incomplete";
|
|
819
|
-
|
|
833
|
+
const relevant = ordered.filter((message) => {
|
|
834
|
+
const candidate = message?.info || message || {};
|
|
835
|
+
if (candidate.role !== "assistant")
|
|
836
|
+
return false;
|
|
837
|
+
if (startedAt <= 0)
|
|
838
|
+
return true;
|
|
839
|
+
const candidateCreated = Number(candidate?.time?.created || 0);
|
|
840
|
+
const candidateCompleted = Number(candidate?.time?.completed || 0);
|
|
841
|
+
return candidateCreated >= startedAt || candidateCompleted >= startedAt;
|
|
842
|
+
});
|
|
843
|
+
return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty";
|
|
820
844
|
}
|
|
821
845
|
async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
|
|
822
846
|
const preferred = normalizedModelRef(preferredModel);
|
|
@@ -902,9 +926,9 @@ function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
|
902
926
|
const entry = loopOwnedUserMessageGuards.get(sessionID);
|
|
903
927
|
if (!entry || typeof entry !== "object")
|
|
904
928
|
return false;
|
|
905
|
-
for (const [
|
|
929
|
+
for (const [id, expiresAt] of entry.messageIDs.entries())
|
|
906
930
|
if (expiresAt < now())
|
|
907
|
-
entry.messageIDs.delete(
|
|
931
|
+
entry.messageIDs.delete(id);
|
|
908
932
|
const id = typeof messageID === "string" ? messageID : "";
|
|
909
933
|
if (id && entry.messageIDs.has(id))
|
|
910
934
|
return true;
|
|
@@ -1593,8 +1617,8 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1593
1617
|
const addLoop = requireFunction2(options.addLoop, "addLoop");
|
|
1594
1618
|
const scheduleDueWork = requireFunction2(options.scheduleDueWork, "scheduleDueWork");
|
|
1595
1619
|
const scheduleIdleWork = requireFunction2(options.scheduleIdleWork, "scheduleIdleWork");
|
|
1596
|
-
const
|
|
1597
|
-
const
|
|
1620
|
+
const toast = requireFunction2(options.toast, "toast");
|
|
1621
|
+
const say = requireFunction2(options.say, "say");
|
|
1598
1622
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1599
1623
|
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
1600
1624
|
const setGoalComplete2 = typeof options.setGoalComplete === "function" ? options.setGoalComplete : setGoalComplete;
|
|
@@ -1611,8 +1635,8 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1611
1635
|
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : "";
|
|
1612
1636
|
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}`;
|
|
1613
1637
|
}) : ["No experimental goal jobs."];
|
|
1614
|
-
await
|
|
1615
|
-
await
|
|
1638
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning");
|
|
1639
|
+
await say(client, sessionID, `OpenCode Loop experimental goal status:
|
|
1616
1640
|
` + lines.join(`
|
|
1617
1641
|
`));
|
|
1618
1642
|
}
|
|
@@ -1623,7 +1647,7 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1623
1647
|
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job);
|
|
1624
1648
|
await writeState2(directory, sessionID, state);
|
|
1625
1649
|
await scheduleDueWork(directory, client, sessionID);
|
|
1626
|
-
await
|
|
1650
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
1627
1651
|
}
|
|
1628
1652
|
async function resumeGoal(directory, client, sessionID, args) {
|
|
1629
1653
|
const target = String(args || "").trim() || "goal";
|
|
@@ -1636,7 +1660,7 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1636
1660
|
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : job.goalStatus || "active", lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" };
|
|
1637
1661
|
});
|
|
1638
1662
|
await writeState2(directory, sessionID, state);
|
|
1639
|
-
await
|
|
1663
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
1640
1664
|
if (count) {
|
|
1641
1665
|
await scheduleDueWork(directory, client, sessionID);
|
|
1642
1666
|
scheduleIdleWork(directory, client, sessionID);
|
|
@@ -1649,15 +1673,15 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1649
1673
|
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || target && !matchJob(job, target, index));
|
|
1650
1674
|
await writeState2(directory, sessionID, state);
|
|
1651
1675
|
await scheduleDueWork(directory, client, sessionID);
|
|
1652
|
-
await
|
|
1676
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
|
|
1653
1677
|
}
|
|
1654
1678
|
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
1655
1679
|
const result = await setGoalComplete2(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true });
|
|
1656
|
-
await
|
|
1680
|
+
await toast(client, result.message, result.ok ? "success" : "warning");
|
|
1657
1681
|
}
|
|
1658
1682
|
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
1659
1683
|
const result = await setGoalBlocked2(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." });
|
|
1660
|
-
await
|
|
1684
|
+
await toast(client, result.message, "warning");
|
|
1661
1685
|
}
|
|
1662
1686
|
async function addGoal(directory, client, sessionID, args) {
|
|
1663
1687
|
const text = String(args || "").trim();
|
|
@@ -1906,8 +1930,8 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1906
1930
|
const stopWatchdog = requireFunction3(options.stopWatchdog, "stopWatchdog");
|
|
1907
1931
|
const scheduleDueWork = requireFunction3(options.scheduleDueWork, "scheduleDueWork");
|
|
1908
1932
|
const maybeRunDueJobs = requireFunction3(options.maybeRunDueJobs, "maybeRunDueJobs");
|
|
1909
|
-
const
|
|
1910
|
-
const
|
|
1933
|
+
const toast = requireFunction3(options.toast, "toast");
|
|
1934
|
+
const say = requireFunction3(options.say, "say");
|
|
1911
1935
|
const now2 = typeof options.now === "function" ? options.now : now;
|
|
1912
1936
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1913
1937
|
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
@@ -1927,7 +1951,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1927
1951
|
clearActiveRun(sessionID);
|
|
1928
1952
|
cancelDueWork(sessionID);
|
|
1929
1953
|
stopWatchdog(sessionID);
|
|
1930
|
-
await
|
|
1954
|
+
await toast(client, "All loops stopped for this session.", "success");
|
|
1931
1955
|
return;
|
|
1932
1956
|
}
|
|
1933
1957
|
const state = await readState2(directory, sessionID);
|
|
@@ -1935,7 +1959,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1935
1959
|
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index));
|
|
1936
1960
|
await writeState2(directory, sessionID, state);
|
|
1937
1961
|
await scheduleDueWork(directory, client, sessionID);
|
|
1938
|
-
await
|
|
1962
|
+
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
|
|
1939
1963
|
}
|
|
1940
1964
|
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
1941
1965
|
const target = String(args || "").trim() || "all";
|
|
@@ -1944,7 +1968,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1944
1968
|
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job);
|
|
1945
1969
|
await writeState2(directory, sessionID, state);
|
|
1946
1970
|
await scheduleDueWork(directory, client, sessionID);
|
|
1947
|
-
await
|
|
1971
|
+
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
|
|
1948
1972
|
}
|
|
1949
1973
|
async function statusLoop(directory, client, sessionID) {
|
|
1950
1974
|
const state = await readState2(directory, sessionID);
|
|
@@ -1955,8 +1979,8 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1955
1979
|
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(",");
|
|
1956
1980
|
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}`;
|
|
1957
1981
|
}) : ["No active loop jobs."];
|
|
1958
|
-
await
|
|
1959
|
-
await
|
|
1982
|
+
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
|
|
1983
|
+
await say(client, sessionID, `OpenCode loop status:
|
|
1960
1984
|
` + lines.join(`
|
|
1961
1985
|
`));
|
|
1962
1986
|
}
|
|
@@ -1966,11 +1990,11 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1966
1990
|
text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
|
|
1967
1991
|
`) || text;
|
|
1968
1992
|
} catch {}
|
|
1969
|
-
await
|
|
1993
|
+
await say(client, sessionID, `OpenCode loop logs:
|
|
1970
1994
|
` + text);
|
|
1971
1995
|
}
|
|
1972
1996
|
async function helpLoop(client, sessionID) {
|
|
1973
|
-
await
|
|
1997
|
+
await say(client, sessionID, [
|
|
1974
1998
|
"OpenCode Loop help:",
|
|
1975
1999
|
"/loop continue the project auto-continue forever whenever the session becomes idle",
|
|
1976
2000
|
"/loop idle continue the project explicit form of the same idle loop",
|
|
@@ -2004,7 +2028,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
2004
2028
|
count += 1;
|
|
2005
2029
|
}
|
|
2006
2030
|
await writeState2(directory, sessionID, state);
|
|
2007
|
-
await
|
|
2031
|
+
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
|
|
2008
2032
|
if (count)
|
|
2009
2033
|
await scheduleDueWork(directory, client, sessionID);
|
|
2010
2034
|
}
|
|
@@ -2034,23 +2058,23 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
2034
2058
|
}
|
|
2035
2059
|
if (otherSessions.length > 8)
|
|
2036
2060
|
lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`);
|
|
2037
|
-
await
|
|
2061
|
+
await say(client, sessionID, lines.join(`
|
|
2038
2062
|
`));
|
|
2039
2063
|
}
|
|
2040
2064
|
async function initLoop(directory, client, sessionID, args) {
|
|
2041
2065
|
const target = String(args || "").trim() || "progress.md";
|
|
2042
2066
|
const full = path7.resolve(directory, target);
|
|
2043
2067
|
if (await pathExists2(full)) {
|
|
2044
|
-
await
|
|
2068
|
+
await toast(client, `${target} already exists.`, "warning");
|
|
2045
2069
|
return;
|
|
2046
2070
|
}
|
|
2047
2071
|
await writeFile(full, DEFAULT_PROGRESS_MD, "utf8");
|
|
2048
|
-
await
|
|
2072
|
+
await toast(client, `Created ${target}.`, "success");
|
|
2049
2073
|
await appendLoopLog2(directory, "init", { sessionID, file: target });
|
|
2050
2074
|
}
|
|
2051
2075
|
async function exportLoop(directory, client, sessionID) {
|
|
2052
2076
|
const state = await readState2(directory, sessionID);
|
|
2053
|
-
await
|
|
2077
|
+
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
|
|
2054
2078
|
}
|
|
2055
2079
|
return {
|
|
2056
2080
|
stopLoop,
|
|
@@ -2160,8 +2184,8 @@ function createLoopRegistration(options = {}) {
|
|
|
2160
2184
|
const snapshotPaths = requireFunction4(options.snapshotPaths, "snapshotPaths");
|
|
2161
2185
|
const scheduleDueWork = requireFunction4(options.scheduleDueWork, "scheduleDueWork");
|
|
2162
2186
|
const scheduleIdleWork = requireFunction4(options.scheduleIdleWork, "scheduleIdleWork");
|
|
2163
|
-
const
|
|
2164
|
-
const
|
|
2187
|
+
const toast = requireFunction4(options.toast, "toast");
|
|
2188
|
+
const say = requireFunction4(options.say, "say");
|
|
2165
2189
|
const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
|
|
2166
2190
|
const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs;
|
|
2167
2191
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
@@ -2175,12 +2199,12 @@ function createLoopRegistration(options = {}) {
|
|
|
2175
2199
|
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
2176
2200
|
const normalized = normalizeLoopScheduleArgs2(args, defaults);
|
|
2177
2201
|
if (!normalized.ok) {
|
|
2178
|
-
await
|
|
2202
|
+
await toast(client, normalized.error, "warning");
|
|
2179
2203
|
return;
|
|
2180
2204
|
}
|
|
2181
2205
|
const parsed = parseLoopArgs2(normalized.args, normalized.defaults);
|
|
2182
2206
|
if (!parsed.ok) {
|
|
2183
|
-
await
|
|
2207
|
+
await toast(client, parsed.error, "warning");
|
|
2184
2208
|
return;
|
|
2185
2209
|
}
|
|
2186
2210
|
parsed.job.scheduleMode = normalized.scheduleMode;
|
|
@@ -2219,13 +2243,13 @@ function createLoopRegistration(options = {}) {
|
|
|
2219
2243
|
job: parsed.job.name || parsed.job.id,
|
|
2220
2244
|
goal: dedicatedGoal.id
|
|
2221
2245
|
});
|
|
2222
|
-
await
|
|
2246
|
+
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");
|
|
2223
2247
|
return;
|
|
2224
2248
|
}
|
|
2225
2249
|
}
|
|
2226
2250
|
if (parsed.job.dryRun) {
|
|
2227
|
-
await
|
|
2228
|
-
await
|
|
2251
|
+
await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
|
|
2252
|
+
await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
|
|
2229
2253
|
return;
|
|
2230
2254
|
}
|
|
2231
2255
|
const state = await readState2(directory, sessionID);
|
|
@@ -2249,7 +2273,7 @@ function createLoopRegistration(options = {}) {
|
|
|
2249
2273
|
await scheduleDueWork(directory, client, sessionID);
|
|
2250
2274
|
if (parsed.job.immediate)
|
|
2251
2275
|
scheduleIdleWork(directory, client, sessionID);
|
|
2252
|
-
await
|
|
2276
|
+
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
|
|
2253
2277
|
await appendLoopLog2(directory, replaced ? "replace" : "add", {
|
|
2254
2278
|
sessionID,
|
|
2255
2279
|
job: parsed.job.name || parsed.job.id,
|
|
@@ -2376,19 +2400,19 @@ function clearSessionActivity(sessionID) {
|
|
|
2376
2400
|
// src/source/runtime/scheduler-diagnostics.js
|
|
2377
2401
|
var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000;
|
|
2378
2402
|
function createSchedulerDiagnostics(options = {}) {
|
|
2379
|
-
const
|
|
2380
|
-
const
|
|
2403
|
+
const now = typeof options.now === "function" ? options.now : Date.now;
|
|
2404
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
|
|
2381
2405
|
const configured = Number(options.throttleMs);
|
|
2382
2406
|
const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS;
|
|
2383
2407
|
const lastLogged = new Map;
|
|
2384
2408
|
async function logDeferral(directory, sessionID, reason, extra = {}) {
|
|
2385
2409
|
const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`;
|
|
2386
|
-
const current =
|
|
2410
|
+
const current = now();
|
|
2387
2411
|
const previous = Number(lastLogged.get(key) || 0);
|
|
2388
2412
|
if (previous > 0 && current - previous < throttleMs)
|
|
2389
2413
|
return false;
|
|
2390
2414
|
lastLogged.set(key, current);
|
|
2391
|
-
await
|
|
2415
|
+
await appendLoopLog(directory, "deferred", {
|
|
2392
2416
|
sessionID,
|
|
2393
2417
|
reason,
|
|
2394
2418
|
...extra
|
|
@@ -2434,7 +2458,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2434
2458
|
if (options.appendLoopLog)
|
|
2435
2459
|
await options.appendLoopLog(directory, event, extra);
|
|
2436
2460
|
};
|
|
2437
|
-
const
|
|
2461
|
+
const toast = async (client, message, level) => {
|
|
2438
2462
|
if (options.toast)
|
|
2439
2463
|
await options.toast(client, message, level);
|
|
2440
2464
|
};
|
|
@@ -2498,7 +2522,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2498
2522
|
}
|
|
2499
2523
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2500
2524
|
}).catch((error) => {
|
|
2501
|
-
|
|
2525
|
+
toast(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {});
|
|
2502
2526
|
appendLog(directory, "idle-error", { sessionID, error: errorMessage(error) }).catch(() => {});
|
|
2503
2527
|
});
|
|
2504
2528
|
}, idleDebounceMs);
|
|
@@ -2510,13 +2534,13 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2510
2534
|
const timer = setIntervalFn(() => {
|
|
2511
2535
|
Promise.resolve().then(async () => {
|
|
2512
2536
|
const state = await readStateFn(directory, sessionID);
|
|
2513
|
-
const
|
|
2537
|
+
const delay = nextDueDelay(state, clock());
|
|
2514
2538
|
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)));
|
|
2515
|
-
if (!hasJobs || !Number.isFinite(
|
|
2539
|
+
if (!hasJobs || !Number.isFinite(delay)) {
|
|
2516
2540
|
stopWatchdog(sessionID);
|
|
2517
2541
|
return;
|
|
2518
2542
|
}
|
|
2519
|
-
if (
|
|
2543
|
+
if (delay <= 0)
|
|
2520
2544
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2521
2545
|
else
|
|
2522
2546
|
await scheduleDueWork(directory, client, sessionID);
|
|
@@ -2533,10 +2557,10 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2533
2557
|
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
2534
2558
|
cancelDueWork(sessionID);
|
|
2535
2559
|
const state = await readStateFn(directory, sessionID);
|
|
2536
|
-
const
|
|
2537
|
-
if (!Number.isFinite(
|
|
2560
|
+
const delay = nextDueDelay(state, clock());
|
|
2561
|
+
if (!Number.isFinite(delay))
|
|
2538
2562
|
return;
|
|
2539
|
-
const wait = Math.min(Math.max(
|
|
2563
|
+
const wait = Math.min(Math.max(delay, minDelayMs, minDueTimerMs), maxDueTimerMs);
|
|
2540
2564
|
const timer = setTimeoutFn(() => {
|
|
2541
2565
|
dueTimers.delete(sessionID);
|
|
2542
2566
|
Promise.resolve().then(async () => {
|
|
@@ -2553,7 +2577,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2553
2577
|
}
|
|
2554
2578
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2555
2579
|
}).catch((error) => {
|
|
2556
|
-
|
|
2580
|
+
toast(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {});
|
|
2557
2581
|
appendLog(directory, "due-timer-error", { sessionID, error: errorMessage(error) }).catch(() => {});
|
|
2558
2582
|
});
|
|
2559
2583
|
}, wait);
|
|
@@ -2592,9 +2616,9 @@ function requireFunction5(value, name) {
|
|
|
2592
2616
|
return value;
|
|
2593
2617
|
}
|
|
2594
2618
|
function createGoalExecutionPolicy(options = {}) {
|
|
2595
|
-
const
|
|
2619
|
+
const runShellCommand = requireFunction5(options.runShellCommand, "runShellCommand");
|
|
2596
2620
|
const dangerousShell = requireFunction5(options.dangerousShell, "dangerousShell");
|
|
2597
|
-
const
|
|
2621
|
+
const toast = requireFunction5(options.toast, "toast");
|
|
2598
2622
|
const now2 = typeof options.now === "function" ? options.now : now;
|
|
2599
2623
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2600
2624
|
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
@@ -2615,7 +2639,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2615
2639
|
job.paused = true;
|
|
2616
2640
|
job.goalNoProgressPausedAt = now2();
|
|
2617
2641
|
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`;
|
|
2618
|
-
await
|
|
2642
|
+
await toast(client, job.goalNoProgressReason, "warning");
|
|
2619
2643
|
await appendLoopLog2(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit });
|
|
2620
2644
|
}
|
|
2621
2645
|
return job;
|
|
@@ -2629,7 +2653,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2629
2653
|
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." });
|
|
2630
2654
|
continue;
|
|
2631
2655
|
}
|
|
2632
|
-
const result = await
|
|
2656
|
+
const result = await runShellCommand(command, directory, job.timeoutMs || 300000);
|
|
2633
2657
|
results.push({ command, code: result.code, output: (result.stdout + `
|
|
2634
2658
|
` + result.stderr).slice(0, 1200) });
|
|
2635
2659
|
}
|
|
@@ -2639,7 +2663,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2639
2663
|
if (allPassed) {
|
|
2640
2664
|
job.goalChecksPassedAt = now2();
|
|
2641
2665
|
job.failureCount = 0;
|
|
2642
|
-
await
|
|
2666
|
+
await toast(client, "Goal checks passed.", "success");
|
|
2643
2667
|
if (job.goalCompleteWhenChecksPass) {
|
|
2644
2668
|
job.goalStatus = "completed";
|
|
2645
2669
|
job.enabled = false;
|
|
@@ -2657,7 +2681,7 @@ exit=${item.code}
|
|
|
2657
2681
|
${item.output}`).join(`
|
|
2658
2682
|
|
|
2659
2683
|
`).slice(0, 4000);
|
|
2660
|
-
await
|
|
2684
|
+
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
|
|
2661
2685
|
}
|
|
2662
2686
|
await appendLoopLog2(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) });
|
|
2663
2687
|
return job;
|
|
@@ -2692,7 +2716,7 @@ function dangerousShell(command) {
|
|
|
2692
2716
|
].some((pattern) => pattern.test(text));
|
|
2693
2717
|
}
|
|
2694
2718
|
function createJobWorkspaceRuntime(options = {}) {
|
|
2695
|
-
const
|
|
2719
|
+
const toast = requireFunction6(options.toast, "toast");
|
|
2696
2720
|
const runProcess2 = typeof options.runProcess === "function" ? options.runProcess : runProcess;
|
|
2697
2721
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2698
2722
|
const readSmallTextFile2 = typeof options.readSmallTextFile === "function" ? options.readSmallTextFile : readSmallTextFile;
|
|
@@ -2736,7 +2760,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2736
2760
|
if (result.code !== 0)
|
|
2737
2761
|
result = await runProcess2("git", ["switch", "-c", branch], directory, 30000);
|
|
2738
2762
|
job.branchDone = true;
|
|
2739
|
-
await
|
|
2763
|
+
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
|
|
2740
2764
|
await appendLoopLog2(directory, "branch", { sessionID, branch, code: result.code });
|
|
2741
2765
|
return job;
|
|
2742
2766
|
}
|
|
@@ -2830,7 +2854,7 @@ ${staged.stdout}`);
|
|
|
2830
2854
|
await runProcess2("git", ["add", "-A"], directory, 120000);
|
|
2831
2855
|
await runProcess2("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120000);
|
|
2832
2856
|
}
|
|
2833
|
-
await
|
|
2857
|
+
await toast(client, `Loop checkpoint saved: ${prefix}`, "success");
|
|
2834
2858
|
}
|
|
2835
2859
|
return {
|
|
2836
2860
|
buildPrompt,
|
|
@@ -2853,6 +2877,9 @@ function nonNegativeNumber(value, fallback) {
|
|
|
2853
2877
|
const number = Number(value);
|
|
2854
2878
|
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
|
2855
2879
|
}
|
|
2880
|
+
function settledAssistantCompletion(value) {
|
|
2881
|
+
return value === "completed" || value === "empty";
|
|
2882
|
+
}
|
|
2856
2883
|
function createSessionStatusRuntime(options = {}) {
|
|
2857
2884
|
const activeRuns = options.activeRuns;
|
|
2858
2885
|
if (!(activeRuns instanceof Map))
|
|
@@ -2941,15 +2968,15 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2941
2968
|
}
|
|
2942
2969
|
return attempted ? { type: "unknown", source: "sdk-error" } : undefined;
|
|
2943
2970
|
}
|
|
2944
|
-
async function canFinalizeActiveRun(directory, client, sessionID, active,
|
|
2971
|
+
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
2945
2972
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
|
|
2946
2973
|
return false;
|
|
2947
|
-
if (!
|
|
2974
|
+
if (!options.requireIdle && !options.forceStale)
|
|
2948
2975
|
return true;
|
|
2949
|
-
const completion =
|
|
2950
|
-
if (completion
|
|
2976
|
+
const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
|
|
2977
|
+
if (settledAssistantCompletion(completion))
|
|
2951
2978
|
return true;
|
|
2952
|
-
if (!
|
|
2979
|
+
if (!options.requireIdle)
|
|
2953
2980
|
return completion === "unknown" && staleActiveRun(sessionID);
|
|
2954
2981
|
const cached = sessionStatuses.get(sessionID);
|
|
2955
2982
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
@@ -2961,11 +2988,11 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2961
2988
|
return true;
|
|
2962
2989
|
}
|
|
2963
2990
|
if (live?.type) {
|
|
2964
|
-
if (live.type === "busy" &&
|
|
2991
|
+
if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2965
2992
|
return true;
|
|
2966
2993
|
return false;
|
|
2967
2994
|
}
|
|
2968
|
-
if (
|
|
2995
|
+
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2969
2996
|
return true;
|
|
2970
2997
|
return cachedIdleAfterRun;
|
|
2971
2998
|
}
|
|
@@ -2977,7 +3004,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2977
3004
|
if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
|
|
2978
3005
|
return false;
|
|
2979
3006
|
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 });
|
|
2980
|
-
if (completion
|
|
3007
|
+
if (!settledAssistantCompletion(completion))
|
|
2981
3008
|
return false;
|
|
2982
3009
|
markSessionStatus(sessionID, "idle");
|
|
2983
3010
|
await appendLoopLog2(directory, "status-message-idle-recovery", {
|
|
@@ -2988,7 +3015,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2988
3015
|
});
|
|
2989
3016
|
return true;
|
|
2990
3017
|
}
|
|
2991
|
-
async function sessionStatusType(client, sessionID, directory,
|
|
3018
|
+
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
2992
3019
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
2993
3020
|
markSessionStatus(sessionID, "busy");
|
|
2994
3021
|
return "busy";
|
|
@@ -3008,11 +3035,11 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3008
3035
|
if (live?.type) {
|
|
3009
3036
|
if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
|
|
3010
3037
|
return "idle";
|
|
3011
|
-
if ((live.type === "busy" || live.type === "retry") &&
|
|
3038
|
+
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
3012
3039
|
const active = activeRuns.get(sessionID);
|
|
3013
3040
|
if (active) {
|
|
3014
3041
|
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
|
|
3015
|
-
if (completion
|
|
3042
|
+
if (settledAssistantCompletion(completion) || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) {
|
|
3016
3043
|
markSessionStatus(sessionID, "idle");
|
|
3017
3044
|
const logDetails = {
|
|
3018
3045
|
sessionID,
|
|
@@ -3020,7 +3047,8 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3020
3047
|
startedAt: active.startedAt,
|
|
3021
3048
|
...completion === "completed" ? {} : { staleStatus: live.type }
|
|
3022
3049
|
};
|
|
3023
|
-
|
|
3050
|
+
const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
|
|
3051
|
+
await appendLoopLog2(directory, recoveryEvent, logDetails);
|
|
3024
3052
|
return "idle";
|
|
3025
3053
|
}
|
|
3026
3054
|
}
|
|
@@ -3032,8 +3060,8 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3032
3060
|
markSessionStatus(sessionID, fallback);
|
|
3033
3061
|
return fallback;
|
|
3034
3062
|
}
|
|
3035
|
-
async function sessionIsIdle(client, sessionID, directory,
|
|
3036
|
-
return await sessionStatusType(client, sessionID, directory,
|
|
3063
|
+
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
3064
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle";
|
|
3037
3065
|
}
|
|
3038
3066
|
return {
|
|
3039
3067
|
markSessionStatus,
|
|
@@ -3042,6 +3070,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3042
3070
|
staleActiveRun,
|
|
3043
3071
|
canFinalizeActiveRun,
|
|
3044
3072
|
readLiveSessionStatus,
|
|
3073
|
+
activeRunCompletion: activeRunCompletionFromMessages2,
|
|
3045
3074
|
sessionStatusType,
|
|
3046
3075
|
sessionIsIdle
|
|
3047
3076
|
};
|
|
@@ -3218,8 +3247,8 @@ function createActionDispatcher(options = {}) {
|
|
|
3218
3247
|
const shellBody = { command, agent };
|
|
3219
3248
|
if (model)
|
|
3220
3249
|
shellBody.model = model;
|
|
3221
|
-
const
|
|
3222
|
-
return { startsAssistantTurn: true, dispatch
|
|
3250
|
+
const dispatch = fireSdk2(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
|
|
3251
|
+
return { startsAssistantTurn: true, dispatch };
|
|
3223
3252
|
}
|
|
3224
3253
|
const prompt = await buildPrompt(directory, job);
|
|
3225
3254
|
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.";
|
|
@@ -3532,6 +3561,50 @@ function refundInfrastructureRun(job, snapshot = {}, input = {}) {
|
|
|
3532
3561
|
return job;
|
|
3533
3562
|
}
|
|
3534
3563
|
|
|
3564
|
+
// src/source/runtime/empty-turn.js
|
|
3565
|
+
var DEFAULT_MAX_EMPTY_TURNS = 2;
|
|
3566
|
+
function guardsEmptyAssistantTurn(job) {
|
|
3567
|
+
const kind = actionKind(job?.action, job || {});
|
|
3568
|
+
return kind === "prompt" || kind === "goal";
|
|
3569
|
+
}
|
|
3570
|
+
function emptyTurnLimit(job) {
|
|
3571
|
+
const configured = Number(job?.maxEmptyTurns || 0);
|
|
3572
|
+
if (Number.isFinite(configured) && configured > 0)
|
|
3573
|
+
return Math.max(1, Math.floor(configured));
|
|
3574
|
+
return DEFAULT_MAX_EMPTY_TURNS;
|
|
3575
|
+
}
|
|
3576
|
+
function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {
|
|
3577
|
+
const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0);
|
|
3578
|
+
const currentCount = Number(job?.runCount || 0);
|
|
3579
|
+
if (chargedCount > 0 && currentCount >= chargedCount)
|
|
3580
|
+
job.runCount = Math.max(0, currentCount - 1);
|
|
3581
|
+
if (Number.isFinite(Number(active?.previousLastRunAt)))
|
|
3582
|
+
job.lastRunAt = Number(active.previousLastRunAt);
|
|
3583
|
+
if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {
|
|
3584
|
+
job.enabled = true;
|
|
3585
|
+
}
|
|
3586
|
+
job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1;
|
|
3587
|
+
job.lastEmptyTurnAt = Number(timestamp) || Date.now();
|
|
3588
|
+
job.lastFailureReason = "empty_turn";
|
|
3589
|
+
const limit = emptyTurnLimit(job);
|
|
3590
|
+
const paused = job.emptyTurnCount >= limit;
|
|
3591
|
+
if (paused) {
|
|
3592
|
+
job.paused = true;
|
|
3593
|
+
delete job.runNowRequestedAt;
|
|
3594
|
+
} else {
|
|
3595
|
+
job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now());
|
|
3596
|
+
}
|
|
3597
|
+
return { job, paused, count: job.emptyTurnCount, limit };
|
|
3598
|
+
}
|
|
3599
|
+
function clearEmptyAssistantTurnStreak(job) {
|
|
3600
|
+
if (!job)
|
|
3601
|
+
return job;
|
|
3602
|
+
job.emptyTurnCount = 0;
|
|
3603
|
+
if (job.lastFailureReason === "empty_turn")
|
|
3604
|
+
delete job.lastFailureReason;
|
|
3605
|
+
return job;
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3535
3608
|
// src/source/runtime/executor.js
|
|
3536
3609
|
var DEFAULT_ACTIVE_GUARD_MS = 45000;
|
|
3537
3610
|
var DEFAULT_BUSY_RETRY_MS2 = 5000;
|
|
@@ -3585,6 +3658,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3585
3658
|
updateSessionStatusFromEvent,
|
|
3586
3659
|
staleActiveRun,
|
|
3587
3660
|
canFinalizeActiveRun,
|
|
3661
|
+
activeRunCompletion,
|
|
3588
3662
|
sessionStatusType,
|
|
3589
3663
|
sessionIsIdle,
|
|
3590
3664
|
markSessionStatus,
|
|
@@ -3637,7 +3711,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3637
3711
|
toast: toast2,
|
|
3638
3712
|
dangerousShell: dangerousShell2
|
|
3639
3713
|
});
|
|
3640
|
-
const
|
|
3714
|
+
const dueJobs = admissionRuntime.dueJobs;
|
|
3641
3715
|
function clearActiveRun(sessionID) {
|
|
3642
3716
|
const active = activeRuns.get(sessionID);
|
|
3643
3717
|
if (active?.timer)
|
|
@@ -3760,6 +3834,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3760
3834
|
return;
|
|
3761
3835
|
if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions))
|
|
3762
3836
|
return false;
|
|
3837
|
+
const completion = await activeRunCompletion(directory, client, sessionID, active);
|
|
3763
3838
|
const recoveredStale = staleActiveRun(sessionID);
|
|
3764
3839
|
if (active.compactionOnly) {
|
|
3765
3840
|
const pending = compactionRuntime.getPending(sessionID);
|
|
@@ -3780,6 +3855,30 @@ function createLoopExecutor(options = {}) {
|
|
|
3780
3855
|
if (!job)
|
|
3781
3856
|
return;
|
|
3782
3857
|
job.lastFinishedAt = now2();
|
|
3858
|
+
if (completion === "empty" && guardsEmptyAssistantTurn(job)) {
|
|
3859
|
+
const empty = refundEmptyAssistantTurn(job, active, now2());
|
|
3860
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3861
|
+
await writeState2(directory, sessionID, state);
|
|
3862
|
+
await appendLoopLog2(directory, "empty-assistant-turn", {
|
|
3863
|
+
sessionID,
|
|
3864
|
+
job: job.name || job.id,
|
|
3865
|
+
count: empty.count,
|
|
3866
|
+
limit: empty.limit,
|
|
3867
|
+
paused: empty.paused,
|
|
3868
|
+
refunded: true
|
|
3869
|
+
});
|
|
3870
|
+
if (empty.paused) {
|
|
3871
|
+
await notifyJob2(directory, job, "empty_turn");
|
|
3872
|
+
await toast2(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning");
|
|
3873
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
3874
|
+
} else {
|
|
3875
|
+
await toast2(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning");
|
|
3876
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
3877
|
+
}
|
|
3878
|
+
return true;
|
|
3879
|
+
}
|
|
3880
|
+
if (completion === "completed")
|
|
3881
|
+
clearEmptyAssistantTurnStreak(job);
|
|
3783
3882
|
if (recoveredStale) {
|
|
3784
3883
|
await appendLoopLog2(directory, "active-stale-recovery", {
|
|
3785
3884
|
sessionID,
|
|
@@ -3835,7 +3934,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3835
3934
|
candidate.watchTriggered = true;
|
|
3836
3935
|
}
|
|
3837
3936
|
}
|
|
3838
|
-
const due =
|
|
3937
|
+
const due = dueJobs(state, Boolean(runOptions.force));
|
|
3839
3938
|
if (!due.length) {
|
|
3840
3939
|
await writeState2(directory, sessionID, state);
|
|
3841
3940
|
await reschedule();
|
|
@@ -3964,7 +4063,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3964
4063
|
}
|
|
3965
4064
|
}
|
|
3966
4065
|
return {
|
|
3967
|
-
dueJobs
|
|
4066
|
+
dueJobs,
|
|
3968
4067
|
clearActiveRun,
|
|
3969
4068
|
disposeSession,
|
|
3970
4069
|
recoverActiveDispatchFailure,
|
|
@@ -4301,47 +4400,73 @@ function steeringToolRejection(sessionID) {
|
|
|
4301
4400
|
output: "Goal lifecycle update deferred because queued user steering is pending. The experimental Goal remains active and unchanged."
|
|
4302
4401
|
};
|
|
4303
4402
|
}
|
|
4403
|
+
async function dedicatedGoalToolRejection(directory, sessionID) {
|
|
4404
|
+
const goal = await findDedicatedGoalForSession(directory, sessionID);
|
|
4405
|
+
if (!goal || goal.status === "completed")
|
|
4406
|
+
return;
|
|
4407
|
+
return {
|
|
4408
|
+
title: "Dedicated Goal owns lifecycle",
|
|
4409
|
+
output: [
|
|
4410
|
+
`A dedicated OpenCode Goal is persisted for this session (status=${String(goal.status || "unknown")}).`,
|
|
4411
|
+
"The opencode_loop_goal_* tools control only experimental /loop-goal jobs and will not mutate the dedicated Goal.",
|
|
4412
|
+
"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."
|
|
4413
|
+
].join(`
|
|
4414
|
+
`)
|
|
4415
|
+
};
|
|
4416
|
+
}
|
|
4304
4417
|
function goalTools(defaultDirectory) {
|
|
4305
4418
|
return {
|
|
4306
4419
|
opencode_loop_goal_complete: tool({
|
|
4307
|
-
description: "
|
|
4420
|
+
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.",
|
|
4308
4421
|
args: {
|
|
4309
4422
|
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
4310
4423
|
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results.")
|
|
4311
4424
|
},
|
|
4312
4425
|
execute: async (args, context) => {
|
|
4426
|
+
const directory = context.directory || defaultDirectory;
|
|
4427
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4428
|
+
if (dedicated)
|
|
4429
|
+
return dedicated;
|
|
4313
4430
|
const steering = steeringToolRejection(context.sessionID);
|
|
4314
4431
|
if (steering)
|
|
4315
4432
|
return steering;
|
|
4316
|
-
const result = await setGoalComplete(
|
|
4433
|
+
const result = await setGoalComplete(directory, context.sessionID, args);
|
|
4317
4434
|
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message };
|
|
4318
4435
|
}
|
|
4319
4436
|
}),
|
|
4320
4437
|
opencode_loop_goal_blocked: tool({
|
|
4321
|
-
description: "
|
|
4438
|
+
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.",
|
|
4322
4439
|
args: {
|
|
4323
4440
|
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
4324
4441
|
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue.")
|
|
4325
4442
|
},
|
|
4326
4443
|
execute: async (args, context) => {
|
|
4444
|
+
const directory = context.directory || defaultDirectory;
|
|
4445
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4446
|
+
if (dedicated)
|
|
4447
|
+
return dedicated;
|
|
4327
4448
|
const steering = steeringToolRejection(context.sessionID);
|
|
4328
4449
|
if (steering)
|
|
4329
4450
|
return steering;
|
|
4330
|
-
const result = await setGoalBlocked(
|
|
4451
|
+
const result = await setGoalBlocked(directory, context.sessionID, args);
|
|
4331
4452
|
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message };
|
|
4332
4453
|
}
|
|
4333
4454
|
}),
|
|
4334
4455
|
opencode_loop_goal_progress: tool({
|
|
4335
|
-
description: "
|
|
4456
|
+
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.",
|
|
4336
4457
|
args: {
|
|
4337
4458
|
summary: tool.schema.string().describe("What useful progress was made."),
|
|
4338
4459
|
next: tool.schema.string().describe("The next step toward completing the goal.")
|
|
4339
4460
|
},
|
|
4340
4461
|
execute: async (args, context) => {
|
|
4462
|
+
const directory = context.directory || defaultDirectory;
|
|
4463
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4464
|
+
if (dedicated)
|
|
4465
|
+
return dedicated;
|
|
4341
4466
|
const steering = steeringToolRejection(context.sessionID);
|
|
4342
4467
|
if (steering)
|
|
4343
4468
|
return steering;
|
|
4344
|
-
const result = await setGoalProgress(
|
|
4469
|
+
const result = await setGoalProgress(directory, context.sessionID, args);
|
|
4345
4470
|
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message };
|
|
4346
4471
|
}
|
|
4347
4472
|
})
|
|
@@ -4408,8 +4533,8 @@ var DEFAULT_SESSION_STALE_MS = 12 * 60 * 60 * 1000;
|
|
|
4408
4533
|
function sessionKey(sessionID) {
|
|
4409
4534
|
return String(sessionID || "").trim();
|
|
4410
4535
|
}
|
|
4411
|
-
function createSessionRegistry({ now
|
|
4412
|
-
if (typeof
|
|
4536
|
+
function createSessionRegistry({ now = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
|
|
4537
|
+
if (typeof now !== "function")
|
|
4413
4538
|
throw new TypeError("session registry requires a clock function");
|
|
4414
4539
|
if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0)
|
|
4415
4540
|
throw new TypeError("session registry requires a non-negative staleAfterMs");
|
|
@@ -4418,7 +4543,7 @@ function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SE
|
|
|
4418
4543
|
const key = sessionKey(sessionID);
|
|
4419
4544
|
if (!key)
|
|
4420
4545
|
throw new TypeError("session registry requires a session ID");
|
|
4421
|
-
const seenAt = Number(
|
|
4546
|
+
const seenAt = Number(now());
|
|
4422
4547
|
if (!Number.isFinite(seenAt))
|
|
4423
4548
|
throw new TypeError("session registry clock must return a finite number");
|
|
4424
4549
|
const entry = Object.freeze({ sessionID: key, runtime, seenAt });
|
|
@@ -4437,7 +4562,7 @@ function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SE
|
|
|
4437
4562
|
return false;
|
|
4438
4563
|
return sessions.delete(key);
|
|
4439
4564
|
}
|
|
4440
|
-
function pruneStale(at =
|
|
4565
|
+
function pruneStale(at = now()) {
|
|
4441
4566
|
const timestamp = Number(at);
|
|
4442
4567
|
if (!Number.isFinite(timestamp))
|
|
4443
4568
|
throw new TypeError("session registry prune time must be finite");
|
|
@@ -4521,7 +4646,7 @@ function createRuntimeScope() {
|
|
|
4521
4646
|
}
|
|
4522
4647
|
|
|
4523
4648
|
// src/source/runtime/timers.js
|
|
4524
|
-
function createOwnedTimer(scope, callback,
|
|
4649
|
+
function createOwnedTimer(scope, callback, delay, repeat, api, ref) {
|
|
4525
4650
|
if (!scope?.isActive?.())
|
|
4526
4651
|
return;
|
|
4527
4652
|
let active = true;
|
|
@@ -4536,7 +4661,7 @@ function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
|
|
|
4536
4661
|
}
|
|
4537
4662
|
callback(...args);
|
|
4538
4663
|
});
|
|
4539
|
-
handle = repeat ? api.setInterval(invoke,
|
|
4664
|
+
handle = repeat ? api.setInterval(invoke, delay) : api.setTimeout(invoke, delay);
|
|
4540
4665
|
release = scope.track(() => {
|
|
4541
4666
|
if (repeat)
|
|
4542
4667
|
api.clearInterval(handle);
|
|
@@ -4562,11 +4687,11 @@ function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
|
|
|
4562
4687
|
}
|
|
4563
4688
|
function createRuntimeTimers(scope, api = globalThis) {
|
|
4564
4689
|
return Object.freeze({
|
|
4565
|
-
timeout(callback,
|
|
4566
|
-
return createOwnedTimer(scope, callback,
|
|
4690
|
+
timeout(callback, delay, options = {}) {
|
|
4691
|
+
return createOwnedTimer(scope, callback, delay, false, api, options.ref !== false);
|
|
4567
4692
|
},
|
|
4568
|
-
interval(callback,
|
|
4569
|
-
return createOwnedTimer(scope, callback,
|
|
4693
|
+
interval(callback, delay, options = {}) {
|
|
4694
|
+
return createOwnedTimer(scope, callback, delay, true, api, options.ref !== false);
|
|
4570
4695
|
}
|
|
4571
4696
|
});
|
|
4572
4697
|
}
|
|
@@ -4591,14 +4716,14 @@ function validateRuntime(runtime, sessionID) {
|
|
|
4591
4716
|
return runtime;
|
|
4592
4717
|
}
|
|
4593
4718
|
function createSessionRuntimeManager({
|
|
4594
|
-
now
|
|
4719
|
+
now = Date.now,
|
|
4595
4720
|
staleAfterMs = DEFAULT_SESSION_STALE_MS,
|
|
4596
4721
|
timerAPI = globalThis,
|
|
4597
4722
|
runtimeFactory = defaultRuntimeFactory
|
|
4598
4723
|
} = {}) {
|
|
4599
4724
|
if (typeof runtimeFactory !== "function")
|
|
4600
4725
|
throw new TypeError("session runtime manager requires a runtime factory");
|
|
4601
|
-
const registry = createSessionRegistry({ now
|
|
4726
|
+
const registry = createSessionRegistry({ now, staleAfterMs });
|
|
4602
4727
|
let disposed = false;
|
|
4603
4728
|
function observeExternal(sessionID) {
|
|
4604
4729
|
if (disposed)
|
|
@@ -4628,7 +4753,7 @@ function createSessionRuntimeManager({
|
|
|
4628
4753
|
current.runtime.dispose(reason);
|
|
4629
4754
|
return true;
|
|
4630
4755
|
}
|
|
4631
|
-
function pruneStale(at =
|
|
4756
|
+
function pruneStale(at = now()) {
|
|
4632
4757
|
const before = new Map(registry.entries().map((entry) => [entry.sessionID, entry.runtime]));
|
|
4633
4758
|
const removed = registry.pruneStale(at);
|
|
4634
4759
|
const errors = [];
|
|
@@ -4829,12 +4954,12 @@ function scopedClient(client) {
|
|
|
4829
4954
|
});
|
|
4830
4955
|
}
|
|
4831
4956
|
function scheduleLegacyCleanup(dispose, isCurrentGeneration) {
|
|
4832
|
-
for (const
|
|
4957
|
+
for (const delay of [0, 25, 100, 500, 2000, 1e4, 30000]) {
|
|
4833
4958
|
const timer = setTimeout(() => {
|
|
4834
4959
|
if (!isCurrentGeneration())
|
|
4835
4960
|
return;
|
|
4836
4961
|
Promise.resolve(dispose()).catch(() => {});
|
|
4837
|
-
},
|
|
4962
|
+
}, delay);
|
|
4838
4963
|
timer.unref?.();
|
|
4839
4964
|
}
|
|
4840
4965
|
}
|