@bybrawe/opencode-loop 0.5.28 → 0.5.29
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 +15 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +11 -3
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +57 -8
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +32 -1
- package/src/source/opencode2/host-contract.js +26 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- package/scripts/host-bridge-parity-test.mjs +0 -22
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { now } from "../core/args.js"
|
|
2
|
+
import { isGoalJob } from "../core/jobs.js"
|
|
3
|
+
import { readState } from "../core/state.js"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_IDLE_DEBOUNCE_MS = 1_200
|
|
6
|
+
const DEFAULT_BUSY_RETRY_MS = 5_000
|
|
7
|
+
const DEFAULT_MIN_DUE_TIMER_MS = 250
|
|
8
|
+
const DEFAULT_MAX_DUE_TIMER_MS = 2_147_000_000
|
|
9
|
+
const DEFAULT_HEARTBEAT_MS = 2_500
|
|
10
|
+
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
export function jobDueAt(job, current = now()) {
|
|
13
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
|
|
14
|
+
if (!job.enabled || job.paused) return Infinity
|
|
15
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
|
|
16
|
+
if (Number(job.runNowRequestedAt || 0) > 0) return current
|
|
17
|
+
if (job.watchPaths?.length) return Infinity
|
|
18
|
+
const created = Date.parse(job.createdAt || "")
|
|
19
|
+
if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs) return current
|
|
20
|
+
if (job.intervalMs === 0) return current
|
|
21
|
+
if (!job.lastRunAt) {
|
|
22
|
+
if (job.immediate === false) return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0)
|
|
23
|
+
return current
|
|
24
|
+
}
|
|
25
|
+
return job.lastRunAt + (job.intervalMs || 0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function nextDueDelay(state, current = now()) {
|
|
29
|
+
let soonest = Infinity
|
|
30
|
+
for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
31
|
+
if (!Number.isFinite(soonest)) return Infinity
|
|
32
|
+
return Math.max(0, soonest - current)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createSchedulerRuntime(options = {}) {
|
|
36
|
+
const idleTimers = new Map()
|
|
37
|
+
const dueTimers = new Map()
|
|
38
|
+
const watchdogTimers = new Map()
|
|
39
|
+
const knownSessions = new Map()
|
|
40
|
+
let heartbeatTimer
|
|
41
|
+
|
|
42
|
+
const clock = options.now || now
|
|
43
|
+
const readStateFn = options.readState || readState
|
|
44
|
+
const setTimeoutFn = options.setTimeout || setTimeout
|
|
45
|
+
const clearTimeoutFn = options.clearTimeout || clearTimeout
|
|
46
|
+
const setIntervalFn = options.setInterval || setInterval
|
|
47
|
+
const clearIntervalFn = options.clearInterval || clearInterval
|
|
48
|
+
const idleDebounceMs = options.idleDebounceMs ?? DEFAULT_IDLE_DEBOUNCE_MS
|
|
49
|
+
const busyRetryMs = options.busyRetryMs ?? DEFAULT_BUSY_RETRY_MS
|
|
50
|
+
const minDueTimerMs = options.minDueTimerMs ?? DEFAULT_MIN_DUE_TIMER_MS
|
|
51
|
+
const maxDueTimerMs = options.maxDueTimerMs ?? DEFAULT_MAX_DUE_TIMER_MS
|
|
52
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS
|
|
53
|
+
const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS
|
|
54
|
+
|
|
55
|
+
const errorMessage = (error) => options.errorMessage ? options.errorMessage(error) : (error instanceof Error ? error.message : String(error || "unknown error"))
|
|
56
|
+
const appendLog = async (directory, event, extra) => { if (options.appendLoopLog) await options.appendLoopLog(directory, event, extra) }
|
|
57
|
+
const toast = async (client, message, level) => { if (options.toast) await options.toast(client, message, level) }
|
|
58
|
+
|
|
59
|
+
function stopHeartbeatIfIdle() {
|
|
60
|
+
if (!knownSessions.size && heartbeatTimer) {
|
|
61
|
+
clearIntervalFn(heartbeatTimer)
|
|
62
|
+
heartbeatTimer = undefined
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function startHeartbeat() {
|
|
67
|
+
if (heartbeatTimer) return
|
|
68
|
+
heartbeatTimer = setIntervalFn(() => {
|
|
69
|
+
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
70
|
+
if (!info || clock() - (info.seenAt || 0) > sessionTtlMs) {
|
|
71
|
+
knownSessions.delete(sessionID)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
Promise.resolve()
|
|
75
|
+
.then(async () => {
|
|
76
|
+
await options.finalizeActiveRun?.(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
|
|
77
|
+
await options.maybeRunDueJobs?.(info.directory, info.client, sessionID, { heartbeat: true })
|
|
78
|
+
})
|
|
79
|
+
.catch((error) => appendLog(info.directory, "heartbeat-error", { sessionID, error: errorMessage(error) }).catch(() => {}))
|
|
80
|
+
}
|
|
81
|
+
stopHeartbeatIfIdle()
|
|
82
|
+
}, heartbeatMs)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rememberSession(directory, client, sessionID) {
|
|
86
|
+
if (!sessionID) return
|
|
87
|
+
knownSessions.set(sessionID, { directory, client, seenAt: clock() })
|
|
88
|
+
startHeartbeat()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cancelIdleWork(sessionID) {
|
|
92
|
+
const timer = idleTimers.get(sessionID)
|
|
93
|
+
if (timer) clearTimeoutFn(timer)
|
|
94
|
+
idleTimers.delete(sessionID)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cancelDueWork(sessionID) {
|
|
98
|
+
const timer = dueTimers.get(sessionID)
|
|
99
|
+
if (timer) clearTimeoutFn(timer)
|
|
100
|
+
dueTimers.delete(sessionID)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scheduleIdleWork(directory, client, sessionID) {
|
|
104
|
+
cancelIdleWork(sessionID)
|
|
105
|
+
const timer = setTimeoutFn(() => {
|
|
106
|
+
idleTimers.delete(sessionID)
|
|
107
|
+
Promise.resolve()
|
|
108
|
+
.then(async () => {
|
|
109
|
+
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
110
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
await options.finalizeActiveRun?.(directory, client, sessionID)
|
|
114
|
+
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
115
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
await options.maybeRunDueJobs?.(directory, client, sessionID)
|
|
119
|
+
})
|
|
120
|
+
.catch((error) => {
|
|
121
|
+
toast(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {})
|
|
122
|
+
appendLog(directory, "idle-error", { sessionID, error: errorMessage(error) }).catch(() => {})
|
|
123
|
+
})
|
|
124
|
+
}, idleDebounceMs)
|
|
125
|
+
idleTimers.set(sessionID, timer)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function startWatchdog(directory, client, sessionID) {
|
|
129
|
+
if (watchdogTimers.has(sessionID)) return
|
|
130
|
+
const timer = setIntervalFn(() => {
|
|
131
|
+
Promise.resolve()
|
|
132
|
+
.then(async () => {
|
|
133
|
+
const state = await readStateFn(directory, sessionID)
|
|
134
|
+
const delay = nextDueDelay(state, clock())
|
|
135
|
+
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
|
|
136
|
+
if (!hasJobs || !Number.isFinite(delay)) {
|
|
137
|
+
stopWatchdog(sessionID)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (delay <= 0) await options.maybeRunDueJobs?.(directory, client, sessionID)
|
|
141
|
+
else await scheduleDueWork(directory, client, sessionID)
|
|
142
|
+
})
|
|
143
|
+
.catch((error) => appendLog(directory, "watchdog-error", { sessionID, error: errorMessage(error) }).catch(() => {}))
|
|
144
|
+
}, Math.max(1_000, busyRetryMs))
|
|
145
|
+
watchdogTimers.set(sessionID, timer)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stopWatchdog(sessionID) {
|
|
149
|
+
const timer = watchdogTimers.get(sessionID)
|
|
150
|
+
if (timer) clearIntervalFn(timer)
|
|
151
|
+
watchdogTimers.delete(sessionID)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
155
|
+
cancelDueWork(sessionID)
|
|
156
|
+
|
|
157
|
+
const state = await readStateFn(directory, sessionID)
|
|
158
|
+
const delay = nextDueDelay(state, clock())
|
|
159
|
+
if (!Number.isFinite(delay)) return
|
|
160
|
+
|
|
161
|
+
const wait = Math.min(Math.max(delay, minDelayMs, minDueTimerMs), maxDueTimerMs)
|
|
162
|
+
const timer = setTimeoutFn(() => {
|
|
163
|
+
dueTimers.delete(sessionID)
|
|
164
|
+
Promise.resolve()
|
|
165
|
+
.then(async () => {
|
|
166
|
+
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
167
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
await options.finalizeActiveRun?.(directory, client, sessionID)
|
|
171
|
+
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
172
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
await options.maybeRunDueJobs?.(directory, client, sessionID)
|
|
176
|
+
})
|
|
177
|
+
.catch((error) => {
|
|
178
|
+
toast(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {})
|
|
179
|
+
appendLog(directory, "due-timer-error", { sessionID, error: errorMessage(error) }).catch(() => {})
|
|
180
|
+
})
|
|
181
|
+
}, wait)
|
|
182
|
+
dueTimers.set(sessionID, timer)
|
|
183
|
+
await startWatchdog(directory, client, sessionID)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sessionIDsForHost(directory, client) {
|
|
187
|
+
return [...knownSessions.entries()]
|
|
188
|
+
.filter(([, info]) => info?.directory === directory && info?.client === client)
|
|
189
|
+
.map(([sessionID]) => sessionID)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function clearSessionScheduling(sessionID) {
|
|
193
|
+
cancelIdleWork(sessionID)
|
|
194
|
+
cancelDueWork(sessionID)
|
|
195
|
+
stopWatchdog(sessionID)
|
|
196
|
+
knownSessions.delete(sessionID)
|
|
197
|
+
stopHeartbeatIfIdle()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
rememberSession,
|
|
202
|
+
scheduleIdleWork,
|
|
203
|
+
scheduleDueWork,
|
|
204
|
+
startWatchdog,
|
|
205
|
+
stopWatchdog,
|
|
206
|
+
cancelIdleWork,
|
|
207
|
+
cancelDueWork,
|
|
208
|
+
sessionIDsForHost,
|
|
209
|
+
clearSessionScheduling,
|
|
210
|
+
knownSessionCount: () => knownSessions.size,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { now } from "../core/args.js"
|
|
2
|
+
import { sdkCall } from "../opencode/sdk.js"
|
|
3
|
+
import { updateSessionExecutionContext, deleteSessionExecutionContext } from "../opencode/session-context.js"
|
|
4
|
+
|
|
5
|
+
export const activeToolCalls = new Map()
|
|
6
|
+
export const sessionParents = new Map()
|
|
7
|
+
export const sessionStatuses = new Map()
|
|
8
|
+
export const sessionStatusSeenAt = new Map()
|
|
9
|
+
|
|
10
|
+
export function hasActiveToolCalls(sessionID) {
|
|
11
|
+
return (activeToolCalls.get(sessionID)?.size || 0) > 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function markToolCallActive(input) {
|
|
15
|
+
const sessionID = input?.sessionID
|
|
16
|
+
const callID = input?.callID
|
|
17
|
+
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
18
|
+
const calls = activeToolCalls.get(sessionID) || new Set()
|
|
19
|
+
calls.add(callID)
|
|
20
|
+
activeToolCalls.set(sessionID, calls)
|
|
21
|
+
sessionStatuses.set(sessionID, "busy")
|
|
22
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function markToolCallFinished(input) {
|
|
26
|
+
const sessionID = input?.sessionID
|
|
27
|
+
const callID = input?.callID
|
|
28
|
+
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
29
|
+
const calls = activeToolCalls.get(sessionID)
|
|
30
|
+
if (!calls) return
|
|
31
|
+
calls.delete(callID)
|
|
32
|
+
if (!calls.size) activeToolCalls.delete(sessionID)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function updateSessionRelationship(info, removed = false) {
|
|
36
|
+
const sessionID = info?.id
|
|
37
|
+
if (typeof sessionID !== "string") return
|
|
38
|
+
if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
|
|
39
|
+
else sessionParents.set(sessionID, info.parentID)
|
|
40
|
+
if (!removed) updateSessionExecutionContext(info)
|
|
41
|
+
else deleteSessionExecutionContext(sessionID)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function updateSessionRelationshipFromEvent(event) {
|
|
45
|
+
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
|
|
46
|
+
updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isDescendantSession(sessionID, ancestorID) {
|
|
50
|
+
const visited = new Set()
|
|
51
|
+
let current = sessionID
|
|
52
|
+
while (sessionParents.has(current) && !visited.has(current)) {
|
|
53
|
+
visited.add(current)
|
|
54
|
+
current = sessionParents.get(current)
|
|
55
|
+
if (current === ancestorID) return true
|
|
56
|
+
}
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function hasBusyDescendant(sessionID) {
|
|
61
|
+
for (const childID of sessionParents.keys()) {
|
|
62
|
+
if (!isDescendantSession(childID, sessionID)) continue
|
|
63
|
+
const status = sessionStatuses.get(childID)
|
|
64
|
+
if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function refreshSessionRelationships(client, directory) {
|
|
70
|
+
if (!client?.session?.list) return
|
|
71
|
+
try {
|
|
72
|
+
const sessions = await sdkCall(
|
|
73
|
+
client.session.list.bind(client.session),
|
|
74
|
+
{ query: { directory } },
|
|
75
|
+
{ directory },
|
|
76
|
+
{},
|
|
77
|
+
)
|
|
78
|
+
if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function updateToolActivityFromEvent(event) {
|
|
83
|
+
const props = event?.properties || {}
|
|
84
|
+
if (event?.type === "message.part.updated") {
|
|
85
|
+
const part = props.part
|
|
86
|
+
if (part?.type !== "tool") return
|
|
87
|
+
const sessionID = part.sessionID || props.sessionID
|
|
88
|
+
if (["pending", "running"].includes(part.state?.status)) {
|
|
89
|
+
markToolCallActive({ sessionID, callID: part.callID })
|
|
90
|
+
}
|
|
91
|
+
if (["completed", "error"].includes(part.state?.status)) {
|
|
92
|
+
// Task/subagent hooks have used the part id as their hook call id in some
|
|
93
|
+
// OpenCode versions, while normal tools use part.callID. Clear either.
|
|
94
|
+
const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
|
|
95
|
+
for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
|
|
96
|
+
}
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
|
|
101
|
+
const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
|
|
102
|
+
if (started) markToolCallActive(props)
|
|
103
|
+
if (finished) markToolCallFinished(props)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function clearSessionActivity(sessionID) {
|
|
107
|
+
activeToolCalls.delete(sessionID)
|
|
108
|
+
sessionParents.delete(sessionID)
|
|
109
|
+
sessionStatuses.delete(sessionID)
|
|
110
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
111
|
+
deleteSessionExecutionContext(sessionID)
|
|
112
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { now as defaultNow } from "../core/args.js"
|
|
2
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
3
|
+
import { sdkData, sdkError } from "../opencode/sdk.js"
|
|
4
|
+
import { activeRunCompletionFromMessages as defaultActiveRunCompletionFromMessages } from "../opencode/host.js"
|
|
5
|
+
import {
|
|
6
|
+
sessionParents,
|
|
7
|
+
sessionStatuses,
|
|
8
|
+
sessionStatusSeenAt,
|
|
9
|
+
hasActiveToolCalls,
|
|
10
|
+
isDescendantSession,
|
|
11
|
+
hasBusyDescendant,
|
|
12
|
+
} from "./session-activity.js"
|
|
13
|
+
|
|
14
|
+
const DEFAULT_STALE_ACTIVE_RECOVERY_MS = 45_000
|
|
15
|
+
const DEFAULT_SESSION_STATUS_CACHE_MS = 1_500
|
|
16
|
+
|
|
17
|
+
function positiveNumber(value, fallback) {
|
|
18
|
+
const number = Number(value)
|
|
19
|
+
return Number.isFinite(number) && number > 0 ? number : fallback
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nonNegativeNumber(value, fallback) {
|
|
23
|
+
const number = Number(value)
|
|
24
|
+
return Number.isFinite(number) && number >= 0 ? number : fallback
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createSessionStatusRuntime(options = {}) {
|
|
28
|
+
const activeRuns = options.activeRuns
|
|
29
|
+
if (!(activeRuns instanceof Map)) throw new TypeError("createSessionStatusRuntime requires activeRuns Map")
|
|
30
|
+
const now = typeof options.now === "function" ? options.now : defaultNow
|
|
31
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
32
|
+
const activeRunCompletionFromMessages = typeof options.activeRunCompletionFromMessages === "function"
|
|
33
|
+
? options.activeRunCompletionFromMessages
|
|
34
|
+
: defaultActiveRunCompletionFromMessages
|
|
35
|
+
const staleActiveRecoveryMs = positiveNumber(options.staleActiveRecoveryMs, DEFAULT_STALE_ACTIVE_RECOVERY_MS)
|
|
36
|
+
const sessionStatusCacheMs = nonNegativeNumber(options.sessionStatusCacheMs, DEFAULT_SESSION_STATUS_CACHE_MS)
|
|
37
|
+
|
|
38
|
+
function markSessionStatus(sessionID, type, observedAt = now()) {
|
|
39
|
+
if (typeof sessionID !== "string" || typeof type !== "string") return false
|
|
40
|
+
sessionStatuses.set(sessionID, type)
|
|
41
|
+
sessionStatusSeenAt.set(sessionID, observedAt)
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clearSessionStatus(sessionID) {
|
|
46
|
+
sessionStatuses.delete(sessionID)
|
|
47
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function updateSessionStatusFromEvent(event) {
|
|
51
|
+
const sessionID = event?.properties?.sessionID
|
|
52
|
+
if (typeof sessionID !== "string") return undefined
|
|
53
|
+
if (event?.type === "session.idle") {
|
|
54
|
+
markSessionStatus(sessionID, "idle")
|
|
55
|
+
return { sessionID, idle: true }
|
|
56
|
+
}
|
|
57
|
+
if (event?.type === "session.status") {
|
|
58
|
+
const status = event?.properties?.status
|
|
59
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
60
|
+
if (typeof type === "string") markSessionStatus(sessionID, type)
|
|
61
|
+
return { sessionID, idle: type === "idle" }
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function staleActiveRun(sessionID) {
|
|
67
|
+
const active = activeRuns.get(sessionID)
|
|
68
|
+
if (!active) return false
|
|
69
|
+
const age = now() - (active.startedAt || 0)
|
|
70
|
+
const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
|
|
71
|
+
const threshold = Number.isFinite(configured) && configured > 0 ? configured : staleActiveRecoveryMs
|
|
72
|
+
return age >= threshold
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
76
|
+
const statusMethod = client?.session?.status
|
|
77
|
+
if (typeof statusMethod !== "function") return undefined
|
|
78
|
+
const argsList = []
|
|
79
|
+
if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
|
|
80
|
+
argsList.push({})
|
|
81
|
+
for (const args of argsList) {
|
|
82
|
+
try {
|
|
83
|
+
const result = await statusMethod.call(client.session, args)
|
|
84
|
+
const error = sdkError(result)
|
|
85
|
+
if (error) continue
|
|
86
|
+
const data = sdkData(result)
|
|
87
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) continue
|
|
88
|
+
const observedAt = now()
|
|
89
|
+
for (const [observedSessionID, observedStatus] of Object.entries(data)) {
|
|
90
|
+
const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
|
|
91
|
+
if (typeof observedType !== "string") continue
|
|
92
|
+
markSessionStatus(observedSessionID, observedType, observedAt)
|
|
93
|
+
}
|
|
94
|
+
// OpenCode's status list contains active sessions; idle sessions are
|
|
95
|
+
// normally omitted. Clear a completed descendant that was previously busy.
|
|
96
|
+
for (const childID of sessionParents.keys()) {
|
|
97
|
+
if (!isDescendantSession(childID, sessionID) || data[childID]) continue
|
|
98
|
+
markSessionStatus(childID, "idle", observedAt)
|
|
99
|
+
}
|
|
100
|
+
if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
|
|
101
|
+
const status = data?.[sessionID]
|
|
102
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
103
|
+
if (typeof type === "string") return { type, source: "sdk" }
|
|
104
|
+
return { type: "idle", source: "sdk" }
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
111
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
|
|
112
|
+
if (!options.requireIdle && !options.forceStale) return true
|
|
113
|
+
|
|
114
|
+
const completion = options.forceStale
|
|
115
|
+
? await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
116
|
+
: undefined
|
|
117
|
+
if (completion === "completed") return true
|
|
118
|
+
if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
|
|
119
|
+
|
|
120
|
+
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
121
|
+
if (live?.type === "idle") return true
|
|
122
|
+
if (live?.type) {
|
|
123
|
+
if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
128
|
+
const cached = sessionStatuses.get(sessionID)
|
|
129
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
130
|
+
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
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
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
138
|
+
markSessionStatus(sessionID, "busy")
|
|
139
|
+
return "busy"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const cached = sessionStatuses.get(sessionID)
|
|
143
|
+
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
|
+
if (cached === "idle") return cached
|
|
151
|
+
if (cached && now() - seenAt < sessionStatusCacheMs) return cached
|
|
152
|
+
|
|
153
|
+
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
154
|
+
if (live?.type) {
|
|
155
|
+
// Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
|
|
156
|
+
// plugin-injected turn until the next user command touches the session.
|
|
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.
|
|
159
|
+
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
160
|
+
const active = activeRuns.get(sessionID)
|
|
161
|
+
if (active) {
|
|
162
|
+
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
163
|
+
if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
|
|
164
|
+
markSessionStatus(sessionID, "idle")
|
|
165
|
+
await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
|
|
166
|
+
sessionID,
|
|
167
|
+
job: active.job?.name || active.jobId,
|
|
168
|
+
startedAt: active.startedAt,
|
|
169
|
+
})
|
|
170
|
+
return "idle"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
markSessionStatus(sessionID, live.type)
|
|
175
|
+
return live.type
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
|
|
179
|
+
markSessionStatus(sessionID, fallback)
|
|
180
|
+
return fallback
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
184
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
markSessionStatus,
|
|
189
|
+
clearSessionStatus,
|
|
190
|
+
updateSessionStatusFromEvent,
|
|
191
|
+
staleActiveRun,
|
|
192
|
+
canFinalizeActiveRun,
|
|
193
|
+
readLiveSessionStatus,
|
|
194
|
+
sessionStatusType,
|
|
195
|
+
sessionIsIdle,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict"
|
|
2
|
-
import { promises as fs } from "node:fs"
|
|
3
|
-
|
|
4
|
-
const legacy = await fs.readFile("src/source/legacy-v1.js", "utf8")
|
|
5
|
-
const host = await fs.readFile("src/source/opencode/host.js", "utf8")
|
|
6
|
-
|
|
7
|
-
const legacyStart = legacy.indexOf("function fireSdk(")
|
|
8
|
-
const legacyEnd = legacy.indexOf("function guardLoopOwnedUserMessage(")
|
|
9
|
-
const hostStart = host.indexOf("export function fireSdk(")
|
|
10
|
-
assert.ok(legacyStart >= 0 && legacyEnd > legacyStart, "legacy host bridge block not found")
|
|
11
|
-
assert.ok(hostStart >= 0, "host bridge module block not found")
|
|
12
|
-
|
|
13
|
-
const normalize = (text) => text
|
|
14
|
-
.replace(/\bexport\s+(?=(?:async\s+)?function\b)/g, "")
|
|
15
|
-
.replace(/\r\n/g, "\n")
|
|
16
|
-
.trim()
|
|
17
|
-
|
|
18
|
-
const legacyBlock = normalize(legacy.slice(legacyStart, legacyEnd))
|
|
19
|
-
const hostBlock = normalize(host.slice(hostStart))
|
|
20
|
-
assert.equal(hostBlock, legacyBlock, "OpenCode host bridge drifted from the stable V1 implementation")
|
|
21
|
-
|
|
22
|
-
console.log("host bridge parity test passed")
|