@bybrawe/opencode-loop 0.5.34 → 0.5.36
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 +20 -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/network-recovery-test.mjs +218 -0
- package/scripts/session-status-test.mjs +18 -4
- package/src/index.js +422 -33
- package/src/source/core/continuation.js +53 -2
- package/src/source/opencode/host.js +23 -2
- package/src/source/runtime/empty-turn.js +46 -0
- package/src/source/runtime/executor.js +173 -16
- package/src/source/runtime/network-recovery.js +40 -0
- package/src/source/runtime/run-finalization.js +28 -0
- package/src/source/runtime/session-status.js +37 -32
- package/src/source/runtime/terminal-guard.js +56 -0
|
@@ -24,6 +24,10 @@ function nonNegativeNumber(value, fallback) {
|
|
|
24
24
|
return Number.isFinite(number) && number >= 0 ? number : fallback
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function settledAssistantCompletion(value) {
|
|
28
|
+
return value === "completed" || value === "empty"
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
export function createSessionStatusRuntime(options = {}) {
|
|
28
32
|
const activeRuns = options.activeRuns
|
|
29
33
|
if (!(activeRuns instanceof Map)) throw new TypeError("createSessionStatusRuntime requires activeRuns Map")
|
|
@@ -78,7 +82,9 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
78
82
|
const argsList = []
|
|
79
83
|
if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
|
|
80
84
|
argsList.push({})
|
|
85
|
+
let attempted = false
|
|
81
86
|
for (const args of argsList) {
|
|
87
|
+
attempted = true
|
|
82
88
|
try {
|
|
83
89
|
const result = await statusMethod.call(client.session, args)
|
|
84
90
|
const error = sdkError(result)
|
|
@@ -91,8 +97,6 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
91
97
|
if (typeof observedType !== "string") continue
|
|
92
98
|
markSessionStatus(observedSessionID, observedType, observedAt)
|
|
93
99
|
}
|
|
94
|
-
// OpenCode's status list contains active sessions; idle sessions are
|
|
95
|
-
// normally omitted. Clear a completed descendant that was previously busy.
|
|
96
100
|
for (const childID of sessionParents.keys()) {
|
|
97
101
|
if (!isDescendantSession(childID, sessionID) || data[childID]) continue
|
|
98
102
|
markSessionStatus(childID, "idle", observedAt)
|
|
@@ -104,7 +108,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
104
108
|
return { type: "idle", source: "sdk" }
|
|
105
109
|
} catch {}
|
|
106
110
|
}
|
|
107
|
-
return undefined
|
|
111
|
+
return attempted ? { type: "unknown", source: "sdk-error" } : undefined
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
@@ -114,30 +118,35 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
114
118
|
const completion = options.forceStale
|
|
115
119
|
? await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
116
120
|
: undefined
|
|
117
|
-
if (completion
|
|
121
|
+
if (settledAssistantCompletion(completion)) return true
|
|
118
122
|
if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
|
|
119
123
|
|
|
124
|
+
const cached = sessionStatuses.get(sessionID)
|
|
125
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
126
|
+
const cachedIdleAfterRun = cached === "idle" && seenAt > (active.startedAt || 0)
|
|
120
127
|
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
121
128
|
if (live?.type === "idle") return true
|
|
129
|
+
if (live?.type === "unknown" && cachedIdleAfterRun) {
|
|
130
|
+
// A concrete idle observed after this active run is sufficient to finalize
|
|
131
|
+
// that run even if a later status read fails. This does not authorize a
|
|
132
|
+
// new prompt by itself; admission applies its own current status policy.
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
122
135
|
if (live?.type) {
|
|
123
|
-
if (
|
|
136
|
+
if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
124
137
|
return false
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
128
|
-
|
|
129
|
-
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
130
|
-
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
141
|
+
return cachedIdleAfterRun
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
|
|
134
145
|
if (activeRuns.has(sessionID)) return false
|
|
135
|
-
if (liveType !== "busy"
|
|
136
|
-
// Never override the first fresh busy observation. Waiting at least one cache
|
|
137
|
-
// window gives the host time to expose a new user/assistant turn if it is real.
|
|
146
|
+
if (liveType !== "busy") return false
|
|
138
147
|
if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false
|
|
139
148
|
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 })
|
|
140
|
-
if (completion
|
|
149
|
+
if (!settledAssistantCompletion(completion)) return false
|
|
141
150
|
markSessionStatus(sessionID, "idle")
|
|
142
151
|
await appendLoopLog(directory, "status-message-idle-recovery", {
|
|
143
152
|
sessionID,
|
|
@@ -149,9 +158,6 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
149
158
|
}
|
|
150
159
|
|
|
151
160
|
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
152
|
-
// OpenCode can briefly report an idle session while a long-running tool or
|
|
153
|
-
// subtask is still executing. Tool lifecycle hooks are the more specific
|
|
154
|
-
// signal here, so never enqueue another turn until every active call ends.
|
|
155
161
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
156
162
|
markSessionStatus(sessionID, "busy")
|
|
157
163
|
return "busy"
|
|
@@ -159,38 +165,36 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
159
165
|
|
|
160
166
|
const cached = sessionStatuses.get(sessionID)
|
|
161
167
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
162
|
-
|
|
163
|
-
// Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
|
|
164
|
-
// trusted briefly: OpenCode custom commands such as /loop-status create their
|
|
165
|
-
// own short assistant turn, and some TUI builds do not always emit the final
|
|
166
|
-
// idle event after that turn. If we cache busy forever, due loop work can get
|
|
167
|
-
// stuck at "due in every idle" until the user types another command.
|
|
168
168
|
if (cached === "idle") return cached
|
|
169
169
|
if (cached && now() - seenAt < sessionStatusCacheMs) return cached
|
|
170
170
|
|
|
171
171
|
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
172
|
+
if (live?.type === "unknown") {
|
|
173
|
+
const conservative = cached === "retry" ? "retry" : "busy"
|
|
174
|
+
markSessionStatus(sessionID, conservative)
|
|
175
|
+
return conservative
|
|
176
|
+
}
|
|
172
177
|
if (live?.type) {
|
|
173
|
-
// Some OpenCode 1.15.x/1.18.x TUI builds can leave session.status at busy
|
|
174
|
-
// after a plugin command acknowledgement. If no Loop run exists yet, a
|
|
175
|
-
// completed assistant tail plus an already-stale busy observation is a
|
|
176
|
-
// stronger signal than the unchanged live status. A genuinely running turn
|
|
177
|
-
// has an unfinished assistant tail (or latest user message) and is never
|
|
178
|
-
// force-recovered by this path.
|
|
179
178
|
if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt)) return "idle"
|
|
180
179
|
|
|
181
|
-
// When a Loop-owned turn exists, use its exact start boundary so an older
|
|
182
|
-
// completed assistant message can never finalize a newer active run.
|
|
183
180
|
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
184
181
|
const active = activeRuns.get(sessionID)
|
|
185
182
|
if (active) {
|
|
186
183
|
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
187
|
-
if (completion === "
|
|
184
|
+
if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {
|
|
188
185
|
markSessionStatus(sessionID, "idle")
|
|
189
|
-
|
|
186
|
+
const logDetails = {
|
|
190
187
|
sessionID,
|
|
191
188
|
job: active.job?.name || active.jobId,
|
|
192
189
|
startedAt: active.startedAt,
|
|
193
|
-
|
|
190
|
+
...(completion === "completed" ? {} : { staleStatus: live.type }),
|
|
191
|
+
}
|
|
192
|
+
const recoveryEvent = completion === "empty"
|
|
193
|
+
? "status-message-empty-recovery"
|
|
194
|
+
: completion === "completed"
|
|
195
|
+
? "status-message-complete-recovery"
|
|
196
|
+
: "status-stale-recovery"
|
|
197
|
+
await appendLoopLog(directory, recoveryEvent, logDetails)
|
|
194
198
|
return "idle"
|
|
195
199
|
}
|
|
196
200
|
}
|
|
@@ -215,6 +219,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
215
219
|
staleActiveRun,
|
|
216
220
|
canFinalizeActiveRun,
|
|
217
221
|
readLiveSessionStatus,
|
|
222
|
+
activeRunCompletion: activeRunCompletionFromMessages,
|
|
218
223
|
sessionStatusType,
|
|
219
224
|
sessionIsIdle,
|
|
220
225
|
}
|
|
@@ -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
|
+
}
|