@bybrawe/opencode-loop 0.5.33 → 0.5.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +197 -283
- package/commands/loop.md +1 -1
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +1 -1
- package/scripts/host-loop-canary.mjs +8 -3
- package/scripts/loop-command-handlers-test.mjs +33 -4
- package/scripts/loop-registration-test.mjs +77 -3
- package/scripts/network-recovery-test.mjs +218 -0
- package/scripts/schedule-policy-test.mjs +114 -0
- package/scripts/schedule-syntax-test.mjs +75 -0
- package/scripts/session-status-idle-recovery-test.mjs +44 -0
- package/scripts/session-status-test.mjs +18 -4
- package/src/index.js +749 -126
- package/src/source/core/continuation.js +71 -0
- package/src/source/core/jobs.js +3 -0
- package/src/source/core/schedule-syntax.js +93 -0
- package/src/source/opencode/loop-commands.js +39 -30
- package/src/source/opencode/loop-registration.js +40 -3
- package/src/source/runtime/companion-goal.js +37 -0
- package/src/source/runtime/executor.js +145 -16
- package/src/source/runtime/loop-diagnostics.js +45 -0
- package/src/source/runtime/network-recovery.js +40 -0
- package/src/source/runtime/run-admission.js +2 -13
- package/src/source/runtime/run-finalization.js +28 -0
- package/src/source/runtime/schedule-policy.js +80 -0
- package/src/source/runtime/scheduler-diagnostics.js +30 -0
- package/src/source/runtime/scheduler.js +10 -23
- package/src/source/runtime/session-status.js +46 -23
- package/src/source/runtime/terminal-guard.js +56 -0
|
@@ -78,7 +78,9 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
78
78
|
const argsList = []
|
|
79
79
|
if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
|
|
80
80
|
argsList.push({})
|
|
81
|
+
let attempted = false
|
|
81
82
|
for (const args of argsList) {
|
|
83
|
+
attempted = true
|
|
82
84
|
try {
|
|
83
85
|
const result = await statusMethod.call(client.session, args)
|
|
84
86
|
const error = sdkError(result)
|
|
@@ -91,8 +93,6 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
91
93
|
if (typeof observedType !== "string") continue
|
|
92
94
|
markSessionStatus(observedSessionID, observedType, observedAt)
|
|
93
95
|
}
|
|
94
|
-
// OpenCode's status list contains active sessions; idle sessions are
|
|
95
|
-
// normally omitted. Clear a completed descendant that was previously busy.
|
|
96
96
|
for (const childID of sessionParents.keys()) {
|
|
97
97
|
if (!isDescendantSession(childID, sessionID) || data[childID]) continue
|
|
98
98
|
markSessionStatus(childID, "idle", observedAt)
|
|
@@ -104,7 +104,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
104
104
|
return { type: "idle", source: "sdk" }
|
|
105
105
|
} catch {}
|
|
106
106
|
}
|
|
107
|
-
return undefined
|
|
107
|
+
return attempted ? { type: "unknown", source: "sdk-error" } : undefined
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
@@ -117,23 +117,43 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
117
117
|
if (completion === "completed") return true
|
|
118
118
|
if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
|
|
119
119
|
|
|
120
|
+
const cached = sessionStatuses.get(sessionID)
|
|
121
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
122
|
+
const cachedIdleAfterRun = cached === "idle" && seenAt > (active.startedAt || 0)
|
|
120
123
|
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
121
124
|
if (live?.type === "idle") return true
|
|
125
|
+
if (live?.type === "unknown" && cachedIdleAfterRun) {
|
|
126
|
+
// A concrete idle observed after this active run is sufficient to finalize
|
|
127
|
+
// that run even if a later status read fails. This does not authorize a
|
|
128
|
+
// new prompt by itself; admission applies its own current status policy.
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
122
131
|
if (live?.type) {
|
|
123
|
-
if (
|
|
132
|
+
if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
124
133
|
return false
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
return cachedIdleAfterRun
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
|
|
141
|
+
if (activeRuns.has(sessionID)) return false
|
|
142
|
+
if (liveType !== "busy") return false
|
|
143
|
+
if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false
|
|
144
|
+
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 })
|
|
145
|
+
if (completion !== "completed") return false
|
|
146
|
+
markSessionStatus(sessionID, "idle")
|
|
147
|
+
await appendLoopLog(directory, "status-message-idle-recovery", {
|
|
148
|
+
sessionID,
|
|
149
|
+
staleStatus: liveType,
|
|
150
|
+
statusSeenAt: seenAt,
|
|
151
|
+
staleForMs: Math.max(0, now() - seenAt),
|
|
152
|
+
})
|
|
153
|
+
return true
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
134
|
-
// OpenCode can briefly report an idle session while a long-running tool or
|
|
135
|
-
// subtask is still executing. Tool lifecycle hooks are the more specific
|
|
136
|
-
// signal here, so never enqueue another turn until every active call ends.
|
|
137
157
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
138
158
|
markSessionStatus(sessionID, "busy")
|
|
139
159
|
return "busy"
|
|
@@ -141,32 +161,35 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
141
161
|
|
|
142
162
|
const cached = sessionStatuses.get(sessionID)
|
|
143
163
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
144
|
-
|
|
145
|
-
// Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
|
|
146
|
-
// trusted briefly: OpenCode custom commands such as /loop-status create their
|
|
147
|
-
// own short assistant turn, and some TUI builds do not always emit the final
|
|
148
|
-
// idle event after that turn. If we cache busy forever, due loop work can get
|
|
149
|
-
// stuck at "due in every idle" until the user types another command.
|
|
150
164
|
if (cached === "idle") return cached
|
|
151
165
|
if (cached && now() - seenAt < sessionStatusCacheMs) return cached
|
|
152
166
|
|
|
153
167
|
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
168
|
+
if (live?.type === "unknown") {
|
|
169
|
+
const conservative = cached === "retry" ? "retry" : "busy"
|
|
170
|
+
markSessionStatus(sessionID, conservative)
|
|
171
|
+
return conservative
|
|
172
|
+
}
|
|
154
173
|
if (live?.type) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// When the only reason we still think the session is busy is our own stale
|
|
158
|
-
// active-run guard, recover instead of waiting for another manual command.
|
|
174
|
+
if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt)) return "idle"
|
|
175
|
+
|
|
159
176
|
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
160
177
|
const active = activeRuns.get(sessionID)
|
|
161
178
|
if (active) {
|
|
162
179
|
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
163
|
-
if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
|
|
180
|
+
if (completion === "completed" || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {
|
|
164
181
|
markSessionStatus(sessionID, "idle")
|
|
165
|
-
|
|
182
|
+
const logDetails = {
|
|
166
183
|
sessionID,
|
|
167
184
|
job: active.job?.name || active.jobId,
|
|
168
185
|
startedAt: active.startedAt,
|
|
169
|
-
|
|
186
|
+
...(completion === "completed" ? {} : { staleStatus: live.type }),
|
|
187
|
+
}
|
|
188
|
+
await appendLoopLog(
|
|
189
|
+
directory,
|
|
190
|
+
completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery",
|
|
191
|
+
logDetails,
|
|
192
|
+
)
|
|
170
193
|
return "idle"
|
|
171
194
|
}
|
|
172
195
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../core/continuation.js"
|
|
2
|
+
import { orderedSessionMessages, readRecentSessionMessages } from "../opencode/host.js"
|
|
3
|
+
|
|
4
|
+
function messageText(message) {
|
|
5
|
+
const parts = Array.isArray(message?.parts) ? message.parts : []
|
|
6
|
+
const fromParts = parts
|
|
7
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
8
|
+
.map((part) => part.text)
|
|
9
|
+
.join("\n")
|
|
10
|
+
.trim()
|
|
11
|
+
if (fromParts) return fromParts
|
|
12
|
+
const info = message?.info || message || {}
|
|
13
|
+
for (const value of [info.text, info.content, info.summary]) {
|
|
14
|
+
if (typeof value === "string" && value.trim()) return value.trim()
|
|
15
|
+
}
|
|
16
|
+
return ""
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
|
|
20
|
+
// `/loop devam et` is intentionally infinite. The guard is only for an
|
|
21
|
+
// explicit completion-bounded request such as "bitene kadar" / "until done".
|
|
22
|
+
if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
|
|
23
|
+
return { job, terminal: false, pausedNow: false }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8)
|
|
27
|
+
if (!messages) return { job, terminal: false, pausedNow: false }
|
|
28
|
+
const tail = orderedSessionMessages(messages).at(-1)
|
|
29
|
+
const info = tail?.info || tail || {}
|
|
30
|
+
if (info.role !== "assistant") return { job, terminal: false, pausedNow: false }
|
|
31
|
+
const completed = Number(info?.time?.completed || 0)
|
|
32
|
+
const created = Number(info?.time?.created || 0)
|
|
33
|
+
const runStarted = Number(job?.lastRunAt || 0)
|
|
34
|
+
if (!Number.isFinite(completed) || completed <= 0) return { job, terminal: false, pausedNow: false }
|
|
35
|
+
if (runStarted > 0 && completed < runStarted && (!Number.isFinite(created) || created < runStarted)) {
|
|
36
|
+
return { job, terminal: false, pausedNow: false }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const text = messageText(tail)
|
|
40
|
+
const terminal = isTerminalNoWorkReply(text)
|
|
41
|
+
if (!terminal) {
|
|
42
|
+
if (job.terminalNoWorkCount) job.terminalNoWorkCount = 0
|
|
43
|
+
return { job, terminal: false, pausedNow: false, text }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1
|
|
47
|
+
job.lastTerminalNoWorkAt = Date.now()
|
|
48
|
+
job.lastTerminalNoWorkSummary = text.slice(0, 1000)
|
|
49
|
+
const threshold = Math.max(2, Number(options.threshold) || 2)
|
|
50
|
+
const pausedNow = job.terminalNoWorkCount >= threshold && !job.paused
|
|
51
|
+
if (pausedNow) {
|
|
52
|
+
job.paused = true
|
|
53
|
+
job.lastFailureReason = "terminal_no_work"
|
|
54
|
+
}
|
|
55
|
+
return { job, terminal: true, pausedNow, text }
|
|
56
|
+
}
|