@bybrawe/opencode-loop 0.5.27 → 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 +7 -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 +29 -7
- package/scripts/comprehensive-watchdog.mjs +32 -0
- 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-adapter-contract-test.mjs +147 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +68 -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-host-canary.mjs +194 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-manager-test.mjs +48 -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-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +82 -0
- package/scripts/v2-plugin-sentinel-test.mjs +136 -0
- 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 +3737 -2220
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +252 -0
- package/src/source/opencode/adapter.js +21 -0
- 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/host.js +134 -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/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +71 -0
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +174 -0
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +47 -0
- package/src/source/opencode2/host-contract.js +94 -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/events.js +113 -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/observer.js +23 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/session-status.js +197 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizeOpenCodeEvent } from "./events.js"
|
|
2
|
+
|
|
3
|
+
export function observeRuntimeEvent(manager, input) {
|
|
4
|
+
const event = normalizeOpenCodeEvent(input)
|
|
5
|
+
if (!event || !manager) return event
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
if (event.kind === "server" && event.action === "disposed") {
|
|
9
|
+
manager.dispose?.("server-disposed")
|
|
10
|
+
return event
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!event.sessionID) return event
|
|
14
|
+
if (event.kind === "session" && event.action === "deleted") {
|
|
15
|
+
manager.remove?.(event.sessionID, { reason: "session-deleted" })
|
|
16
|
+
return event
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
manager.observeExternal?.(event.sessionID)
|
|
20
|
+
} catch {}
|
|
21
|
+
|
|
22
|
+
return event
|
|
23
|
+
}
|
|
@@ -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,56 @@
|
|
|
1
|
+
export function createRuntimeScope() {
|
|
2
|
+
const controller = new AbortController()
|
|
3
|
+
const cleanups = new Set()
|
|
4
|
+
let disposed = false
|
|
5
|
+
|
|
6
|
+
function track(cleanup) {
|
|
7
|
+
if (typeof cleanup !== "function") throw new TypeError("runtime scope cleanup must be a function")
|
|
8
|
+
if (disposed) {
|
|
9
|
+
cleanup()
|
|
10
|
+
return () => false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const entry = { cleanup }
|
|
14
|
+
cleanups.add(entry)
|
|
15
|
+
let tracked = true
|
|
16
|
+
return () => {
|
|
17
|
+
if (!tracked) return false
|
|
18
|
+
tracked = false
|
|
19
|
+
return cleanups.delete(entry)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function guard(callback) {
|
|
24
|
+
if (typeof callback !== "function") throw new TypeError("runtime scope guard requires a function")
|
|
25
|
+
return function (...args) {
|
|
26
|
+
if (disposed) return undefined
|
|
27
|
+
return callback.apply(this, args)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dispose(reason) {
|
|
32
|
+
if (disposed) return false
|
|
33
|
+
disposed = true
|
|
34
|
+
controller.abort(reason)
|
|
35
|
+
|
|
36
|
+
const errors = []
|
|
37
|
+
for (const entry of [...cleanups].reverse()) {
|
|
38
|
+
cleanups.delete(entry)
|
|
39
|
+
try {
|
|
40
|
+
entry.cleanup()
|
|
41
|
+
} catch (error) {
|
|
42
|
+
errors.push(error)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (errors.length) throw new AggregateError(errors, "runtime scope cleanup failed")
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
isActive: () => !disposed,
|
|
52
|
+
track,
|
|
53
|
+
guard,
|
|
54
|
+
dispose,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -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,100 @@
|
|
|
1
|
+
import { DEFAULT_SESSION_STALE_MS, createSessionRegistry } from "./session-registry.js"
|
|
2
|
+
import { createRuntimeScope } from "./scope.js"
|
|
3
|
+
import { createRuntimeTimers } from "./timers.js"
|
|
4
|
+
|
|
5
|
+
function defaultRuntimeFactory({ sessionID, timerAPI }) {
|
|
6
|
+
const scope = createRuntimeScope()
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
sessionID,
|
|
9
|
+
scope,
|
|
10
|
+
timers: createRuntimeTimers(scope, timerAPI),
|
|
11
|
+
dispose: (reason) => scope.dispose(reason),
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validateRuntime(runtime, sessionID) {
|
|
16
|
+
if (!runtime || runtime.sessionID !== sessionID) throw new TypeError("session runtime factory must preserve the session ID")
|
|
17
|
+
if (typeof runtime?.scope?.isActive !== "function") throw new TypeError("session runtime factory must provide an active scope")
|
|
18
|
+
if (typeof runtime.dispose !== "function") throw new TypeError("session runtime factory must provide dispose()")
|
|
19
|
+
return runtime
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSessionRuntimeManager({
|
|
23
|
+
now = Date.now,
|
|
24
|
+
staleAfterMs = DEFAULT_SESSION_STALE_MS,
|
|
25
|
+
timerAPI = globalThis,
|
|
26
|
+
runtimeFactory = defaultRuntimeFactory,
|
|
27
|
+
} = {}) {
|
|
28
|
+
if (typeof runtimeFactory !== "function") throw new TypeError("session runtime manager requires a runtime factory")
|
|
29
|
+
const registry = createSessionRegistry({ now, staleAfterMs })
|
|
30
|
+
let disposed = false
|
|
31
|
+
|
|
32
|
+
function observeExternal(sessionID) {
|
|
33
|
+
if (disposed) throw new Error("session runtime manager is disposed")
|
|
34
|
+
const key = String(sessionID || "").trim()
|
|
35
|
+
if (!key) throw new TypeError("session runtime manager requires a session ID")
|
|
36
|
+
const current = registry.peek(key)
|
|
37
|
+
const runtime = current?.runtime?.scope?.isActive?.()
|
|
38
|
+
? current.runtime
|
|
39
|
+
: validateRuntime(runtimeFactory({ sessionID: key, timerAPI }), key)
|
|
40
|
+
registry.observeExternal(key, runtime)
|
|
41
|
+
return runtime
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function peek(sessionID) {
|
|
45
|
+
return registry.peek(sessionID)?.runtime
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function entries() {
|
|
49
|
+
return registry.entries()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function remove(sessionID, { expectedRuntime, reason } = {}) {
|
|
53
|
+
const current = registry.peek(sessionID)
|
|
54
|
+
if (!current) return false
|
|
55
|
+
if (expectedRuntime !== undefined && current.runtime !== expectedRuntime) return false
|
|
56
|
+
if (!registry.remove(sessionID, current.runtime)) return false
|
|
57
|
+
current.runtime.dispose(reason)
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pruneStale(at = now()) {
|
|
62
|
+
const before = new Map(registry.entries().map((entry) => [entry.sessionID, entry.runtime]))
|
|
63
|
+
const removed = registry.pruneStale(at)
|
|
64
|
+
const errors = []
|
|
65
|
+
for (const sessionID of removed) {
|
|
66
|
+
try {
|
|
67
|
+
before.get(sessionID)?.dispose("stale-session")
|
|
68
|
+
} catch (error) {
|
|
69
|
+
errors.push(error)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (errors.length) throw new AggregateError(errors, "stale session cleanup failed")
|
|
73
|
+
return removed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dispose(reason) {
|
|
77
|
+
if (disposed) return false
|
|
78
|
+
disposed = true
|
|
79
|
+
const errors = []
|
|
80
|
+
for (const entry of registry.entries()) {
|
|
81
|
+
registry.remove(entry.sessionID, entry.runtime)
|
|
82
|
+
try {
|
|
83
|
+
entry.runtime.dispose(reason)
|
|
84
|
+
} catch (error) {
|
|
85
|
+
errors.push(error)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (errors.length) throw new AggregateError(errors, "session runtime manager cleanup failed")
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
observeExternal,
|
|
94
|
+
peek,
|
|
95
|
+
entries,
|
|
96
|
+
remove,
|
|
97
|
+
pruneStale,
|
|
98
|
+
dispose,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const DEFAULT_SESSION_STALE_MS = 12 * 60 * 60 * 1000
|
|
2
|
+
|
|
3
|
+
function sessionKey(sessionID) {
|
|
4
|
+
return String(sessionID || "").trim()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createSessionRegistry({ now = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
|
|
8
|
+
if (typeof now !== "function") throw new TypeError("session registry requires a clock function")
|
|
9
|
+
if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) throw new TypeError("session registry requires a non-negative staleAfterMs")
|
|
10
|
+
|
|
11
|
+
const sessions = new Map()
|
|
12
|
+
|
|
13
|
+
function observeExternal(sessionID, runtime) {
|
|
14
|
+
const key = sessionKey(sessionID)
|
|
15
|
+
if (!key) throw new TypeError("session registry requires a session ID")
|
|
16
|
+
const seenAt = Number(now())
|
|
17
|
+
if (!Number.isFinite(seenAt)) throw new TypeError("session registry clock must return a finite number")
|
|
18
|
+
const entry = Object.freeze({ sessionID: key, runtime, seenAt })
|
|
19
|
+
sessions.set(key, entry)
|
|
20
|
+
return entry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function peek(sessionID) {
|
|
24
|
+
return sessions.get(sessionKey(sessionID))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function remove(sessionID, expectedRuntime) {
|
|
28
|
+
const key = sessionKey(sessionID)
|
|
29
|
+
const current = sessions.get(key)
|
|
30
|
+
if (!current) return false
|
|
31
|
+
if (arguments.length > 1 && current.runtime !== expectedRuntime) return false
|
|
32
|
+
return sessions.delete(key)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pruneStale(at = now()) {
|
|
36
|
+
const timestamp = Number(at)
|
|
37
|
+
if (!Number.isFinite(timestamp)) throw new TypeError("session registry prune time must be finite")
|
|
38
|
+
const removed = []
|
|
39
|
+
for (const [key, entry] of sessions) {
|
|
40
|
+
if (timestamp - entry.seenAt < staleAfterMs) continue
|
|
41
|
+
sessions.delete(key)
|
|
42
|
+
removed.push(key)
|
|
43
|
+
}
|
|
44
|
+
return removed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function entries() {
|
|
48
|
+
return [...sessions.values()]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
observeExternal,
|
|
53
|
+
peek,
|
|
54
|
+
remove,
|
|
55
|
+
pruneStale,
|
|
56
|
+
entries,
|
|
57
|
+
})
|
|
58
|
+
}
|