@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,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { normalizeOpenCodeEvent } from "../src/source/runtime/events.js"
|
|
3
|
+
|
|
4
|
+
assert.deepEqual(
|
|
5
|
+
normalizeOpenCodeEvent({ type: "session.idle", properties: { sessionID: "ses_idle" } }),
|
|
6
|
+
{ kind: "session", action: "idle", sessionID: "ses_idle", directory: undefined },
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
assert.deepEqual(
|
|
10
|
+
normalizeOpenCodeEvent({
|
|
11
|
+
directory: "/workspace",
|
|
12
|
+
payload: { type: "session.status", properties: { sessionID: "ses_busy", status: { type: "busy" } } },
|
|
13
|
+
}),
|
|
14
|
+
{ kind: "session", action: "status", sessionID: "ses_busy", directory: "/workspace", status: "busy" },
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
assert.deepEqual(
|
|
18
|
+
normalizeOpenCodeEvent({
|
|
19
|
+
type: "session.created",
|
|
20
|
+
properties: { info: { id: "ses_child", directory: "/project", parentID: "ses_parent" } },
|
|
21
|
+
}),
|
|
22
|
+
{ kind: "session", action: "created", sessionID: "ses_child", directory: "/project", parentID: "ses_parent" },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
assert.deepEqual(
|
|
26
|
+
normalizeOpenCodeEvent({
|
|
27
|
+
directory: "/workspace",
|
|
28
|
+
payload: {
|
|
29
|
+
type: "message.updated",
|
|
30
|
+
properties: {
|
|
31
|
+
info: {
|
|
32
|
+
id: "msg_done",
|
|
33
|
+
sessionID: "ses_message",
|
|
34
|
+
role: "assistant",
|
|
35
|
+
time: { created: 10, completed: 20 },
|
|
36
|
+
finish: "stop",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
{
|
|
42
|
+
kind: "message",
|
|
43
|
+
action: "updated",
|
|
44
|
+
sessionID: "ses_message",
|
|
45
|
+
directory: "/workspace",
|
|
46
|
+
messageID: "msg_done",
|
|
47
|
+
role: "assistant",
|
|
48
|
+
completedAt: 20,
|
|
49
|
+
finish: "stop",
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
normalizeOpenCodeEvent({
|
|
55
|
+
type: "command.executed",
|
|
56
|
+
properties: { name: "loop-status", sessionID: "ses_command", arguments: "--json", messageID: "msg_command" },
|
|
57
|
+
}),
|
|
58
|
+
{
|
|
59
|
+
kind: "command",
|
|
60
|
+
action: "executed",
|
|
61
|
+
sessionID: "ses_command",
|
|
62
|
+
directory: undefined,
|
|
63
|
+
name: "loop-status",
|
|
64
|
+
arguments: "--json",
|
|
65
|
+
messageID: "msg_command",
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
normalizeOpenCodeEvent({ type: "server.instance.disposed", properties: { directory: "/old" } }),
|
|
71
|
+
{ kind: "server", action: "disposed", directory: "/old" },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert.equal(normalizeOpenCodeEvent({ type: "session.idle", properties: {} }), undefined)
|
|
75
|
+
assert.equal(normalizeOpenCodeEvent({ payload: { properties: {} } }), undefined)
|
|
76
|
+
assert.equal(normalizeOpenCodeEvent({ type: "file.edited", properties: { file: "x.txt" } }), undefined)
|
|
77
|
+
|
|
78
|
+
console.log("OpenCode runtime event normalization test passed")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createRuntimeScope } from "../src/source/runtime/scope.js"
|
|
3
|
+
import { createRuntimeTimers } from "../src/source/runtime/timers.js"
|
|
4
|
+
|
|
5
|
+
const scope = createRuntimeScope()
|
|
6
|
+
const events = []
|
|
7
|
+
scope.track(() => events.push("first"))
|
|
8
|
+
const release = scope.track(() => events.push("released"))
|
|
9
|
+
scope.track(() => events.push("last"))
|
|
10
|
+
assert.equal(release(), true)
|
|
11
|
+
|
|
12
|
+
let calls = 0
|
|
13
|
+
const guarded = scope.guard(() => ++calls)
|
|
14
|
+
assert.equal(guarded(), 1)
|
|
15
|
+
|
|
16
|
+
const cleared = []
|
|
17
|
+
const api = {
|
|
18
|
+
setTimeout(callback) { return { callback } },
|
|
19
|
+
clearTimeout(handle) { cleared.push(handle) },
|
|
20
|
+
setInterval(callback) { return { callback } },
|
|
21
|
+
clearInterval(handle) { cleared.push(handle) },
|
|
22
|
+
}
|
|
23
|
+
const timers = createRuntimeTimers(scope, api)
|
|
24
|
+
const repeating = timers.interval(() => ++calls, 1)
|
|
25
|
+
repeating.handle.callback()
|
|
26
|
+
assert.equal(calls, 2)
|
|
27
|
+
assert.equal(repeating.cancel(), true)
|
|
28
|
+
repeating.handle.callback()
|
|
29
|
+
assert.equal(calls, 2)
|
|
30
|
+
const pending = timers.interval(() => ++calls, 1)
|
|
31
|
+
|
|
32
|
+
assert.equal(scope.dispose("done"), true)
|
|
33
|
+
assert.deepEqual(events, ["last", "first"])
|
|
34
|
+
assert.equal(scope.signal.aborted, true)
|
|
35
|
+
assert.equal(scope.isActive(), false)
|
|
36
|
+
pending.handle.callback()
|
|
37
|
+
assert.equal(calls, 2)
|
|
38
|
+
assert.ok(cleared.includes(pending.handle))
|
|
39
|
+
assert.equal(timers.timeout(() => {}, 1), undefined)
|
|
40
|
+
assert.equal(scope.dispose(), false)
|
|
41
|
+
assert.equal(guarded(), undefined)
|
|
42
|
+
|
|
43
|
+
scope.track(() => events.push("late"))
|
|
44
|
+
assert.deepEqual(events, ["last", "first", "late"])
|
|
45
|
+
|
|
46
|
+
console.log("OpenCode runtime scope test passed")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createSchedulerRuntime, jobDueAt, nextDueDelay } from "../src/source/runtime/scheduler.js"
|
|
3
|
+
|
|
4
|
+
const baseJob = (overrides = {}) => ({
|
|
5
|
+
id: "job-1",
|
|
6
|
+
enabled: true,
|
|
7
|
+
paused: false,
|
|
8
|
+
maxRuns: 0,
|
|
9
|
+
runCount: 0,
|
|
10
|
+
watchPaths: [],
|
|
11
|
+
createdAt: new Date(0).toISOString(),
|
|
12
|
+
intervalMs: 100,
|
|
13
|
+
lastRunAt: 950,
|
|
14
|
+
...overrides,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
assert.equal(jobDueAt(baseJob(), 1_000), 1_050)
|
|
18
|
+
assert.equal(jobDueAt(baseJob({ intervalMs: 0 }), 1_000), 1_000)
|
|
19
|
+
assert.equal(jobDueAt(baseJob({ lastRunAt: 0 }), 1_000), 1_000)
|
|
20
|
+
assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: true, createdAt: new Date(900).toISOString(), intervalMs: 200 }), 1_000), 1_000)
|
|
21
|
+
assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200 }), 1_000), 1_100)
|
|
22
|
+
assert.equal(nextDueDelay({ jobs: [baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200 })] }, 1_000), 100)
|
|
23
|
+
assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200, watchPaths: ["x"], runNowRequestedAt: 999 }), 1_000), 1_000)
|
|
24
|
+
assert.equal(nextDueDelay({ jobs: [baseJob({ lastRunAt: 0, immediate: false, intervalMs: 200, watchPaths: ["x"], runNowRequestedAt: 999 })] }, 1_000), 0)
|
|
25
|
+
assert.equal(jobDueAt(baseJob({ paused: true, runNowRequestedAt: 999 }), 1_000), Infinity)
|
|
26
|
+
assert.equal(jobDueAt(baseJob({ enabled: false, runNowRequestedAt: 999 }), 1_000), Infinity)
|
|
27
|
+
assert.equal(jobDueAt(baseJob({ maxRuns: 2, runCount: 2, runNowRequestedAt: 999 }), 1_000), Infinity)
|
|
28
|
+
assert.equal(jobDueAt(baseJob({ kind: "goal", goalStatus: "completed", runNowRequestedAt: 999 }), 1_000), Infinity)
|
|
29
|
+
assert.equal(nextDueDelay({ jobs: [baseJob(), baseJob({ paused: true })] }, 1_000), 50)
|
|
30
|
+
assert.equal(nextDueDelay({ jobs: [baseJob({ paused: true })] }, 1_000), Infinity)
|
|
31
|
+
|
|
32
|
+
function fakeTimers() {
|
|
33
|
+
let id = 0
|
|
34
|
+
const timeouts = new Map()
|
|
35
|
+
const intervals = new Map()
|
|
36
|
+
const setTimeout = (fn, ms) => {
|
|
37
|
+
const timer = { id: ++id, fn, ms }
|
|
38
|
+
timeouts.set(timer.id, timer)
|
|
39
|
+
return timer
|
|
40
|
+
}
|
|
41
|
+
const clearTimeout = (timer) => { if (timer) timeouts.delete(timer.id) }
|
|
42
|
+
const setInterval = (fn, ms) => {
|
|
43
|
+
const timer = { id: ++id, fn, ms }
|
|
44
|
+
intervals.set(timer.id, timer)
|
|
45
|
+
return timer
|
|
46
|
+
}
|
|
47
|
+
const clearInterval = (timer) => { if (timer) intervals.delete(timer.id) }
|
|
48
|
+
const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve() }
|
|
49
|
+
const fireTimeout = async (timer) => {
|
|
50
|
+
timeouts.delete(timer.id)
|
|
51
|
+
timer.fn()
|
|
52
|
+
await flush()
|
|
53
|
+
}
|
|
54
|
+
const fireInterval = async (timer) => {
|
|
55
|
+
timer.fn()
|
|
56
|
+
await flush()
|
|
57
|
+
}
|
|
58
|
+
return { timeouts, intervals, setTimeout, clearTimeout, setInterval, clearInterval, fireTimeout, fireInterval }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const timers = fakeTimers()
|
|
62
|
+
let clock = 1_000
|
|
63
|
+
const events = []
|
|
64
|
+
const state = { jobs: [baseJob()] }
|
|
65
|
+
const client = { name: "fake-client" }
|
|
66
|
+
const runtime = createSchedulerRuntime({
|
|
67
|
+
now: () => clock,
|
|
68
|
+
readState: async () => state,
|
|
69
|
+
sessionIsIdle: async () => true,
|
|
70
|
+
finalizeActiveRun: async (_directory, _client, _sessionID, options) => events.push(options?.forceStale ? "heartbeat-finalize" : "finalize"),
|
|
71
|
+
maybeRunDueJobs: async (_directory, _client, _sessionID, options) => events.push(options?.heartbeat ? "heartbeat-run" : "run"),
|
|
72
|
+
appendLoopLog: async () => {},
|
|
73
|
+
toast: async () => {},
|
|
74
|
+
errorMessage: (error) => error?.message || String(error),
|
|
75
|
+
setTimeout: timers.setTimeout,
|
|
76
|
+
clearTimeout: timers.clearTimeout,
|
|
77
|
+
setInterval: timers.setInterval,
|
|
78
|
+
clearInterval: timers.clearInterval,
|
|
79
|
+
idleDebounceMs: 5,
|
|
80
|
+
busyRetryMs: 7,
|
|
81
|
+
minDueTimerMs: 1,
|
|
82
|
+
maxDueTimerMs: 1_000,
|
|
83
|
+
heartbeatMs: 10,
|
|
84
|
+
sessionTtlMs: 1_000,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
runtime.rememberSession("/workspace", client, "ses-1")
|
|
88
|
+
runtime.rememberSession("/workspace", client, "ses-1")
|
|
89
|
+
assert.equal(runtime.knownSessionCount(), 1)
|
|
90
|
+
assert.equal(timers.intervals.size, 1, "heartbeat must be deduplicated")
|
|
91
|
+
|
|
92
|
+
runtime.scheduleIdleWork("/workspace", client, "ses-1")
|
|
93
|
+
runtime.scheduleIdleWork("/workspace", client, "ses-1")
|
|
94
|
+
assert.equal(timers.timeouts.size, 1, "idle timer must replace the previous timer")
|
|
95
|
+
const idleTimer = [...timers.timeouts.values()][0]
|
|
96
|
+
assert.equal(idleTimer.ms, 5)
|
|
97
|
+
await timers.fireTimeout(idleTimer)
|
|
98
|
+
assert.deepEqual(events.splice(0), ["finalize", "run"])
|
|
99
|
+
|
|
100
|
+
await runtime.scheduleDueWork("/workspace", client, "ses-1")
|
|
101
|
+
assert.equal(timers.intervals.size, 2, "watchdog should run alongside heartbeat")
|
|
102
|
+
const dueTimer = [...timers.timeouts.values()][0]
|
|
103
|
+
assert.equal(dueTimer.ms, 50)
|
|
104
|
+
await timers.fireTimeout(dueTimer)
|
|
105
|
+
assert.deepEqual(events.splice(0), ["finalize", "run"])
|
|
106
|
+
|
|
107
|
+
const heartbeat = [...timers.intervals.values()].find((timer) => timer.ms === 10)
|
|
108
|
+
assert.ok(heartbeat)
|
|
109
|
+
await timers.fireInterval(heartbeat)
|
|
110
|
+
assert.deepEqual(events.splice(0), ["heartbeat-finalize", "heartbeat-run"])
|
|
111
|
+
|
|
112
|
+
await runtime.scheduleDueWork("/workspace", client, "ses-1")
|
|
113
|
+
assert.ok(timers.timeouts.size > 0)
|
|
114
|
+
runtime.clearSessionScheduling("ses-1")
|
|
115
|
+
assert.equal(runtime.knownSessionCount(), 0)
|
|
116
|
+
assert.equal(timers.timeouts.size, 0)
|
|
117
|
+
assert.equal(timers.intervals.size, 0, "clearing the last session should stop watchdog and heartbeat")
|
|
118
|
+
|
|
119
|
+
const busyTimers = fakeTimers()
|
|
120
|
+
let idleChecks = 0
|
|
121
|
+
const busyRuntime = createSchedulerRuntime({
|
|
122
|
+
now: () => 2_000,
|
|
123
|
+
readState: async () => ({ jobs: [baseJob({ intervalMs: 0, lastRunAt: 0 })] }),
|
|
124
|
+
sessionIsIdle: async () => (++idleChecks > 1),
|
|
125
|
+
finalizeActiveRun: async () => { throw new Error("finalize should not run while first idle check is busy") },
|
|
126
|
+
maybeRunDueJobs: async () => {},
|
|
127
|
+
appendLoopLog: async () => {},
|
|
128
|
+
toast: async () => {},
|
|
129
|
+
setTimeout: busyTimers.setTimeout,
|
|
130
|
+
clearTimeout: busyTimers.clearTimeout,
|
|
131
|
+
setInterval: busyTimers.setInterval,
|
|
132
|
+
clearInterval: busyTimers.clearInterval,
|
|
133
|
+
idleDebounceMs: 3,
|
|
134
|
+
busyRetryMs: 7,
|
|
135
|
+
minDueTimerMs: 1,
|
|
136
|
+
heartbeatMs: 10,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
busyRuntime.scheduleIdleWork("/workspace", client, "ses-busy")
|
|
140
|
+
const busyIdleTimer = [...busyTimers.timeouts.values()][0]
|
|
141
|
+
await busyTimers.fireTimeout(busyIdleTimer)
|
|
142
|
+
const retryTimer = [...busyTimers.timeouts.values()][0]
|
|
143
|
+
assert.ok(retryTimer)
|
|
144
|
+
assert.equal(retryTimer.ms, 7, "busy idle handler should reschedule with busy retry delay")
|
|
145
|
+
busyRuntime.clearSessionScheduling("ses-busy")
|
|
146
|
+
|
|
147
|
+
console.log("scheduler runtime tests passed")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {
|
|
3
|
+
activeToolCalls,
|
|
4
|
+
sessionParents,
|
|
5
|
+
sessionStatuses,
|
|
6
|
+
sessionStatusSeenAt,
|
|
7
|
+
hasActiveToolCalls,
|
|
8
|
+
markToolCallActive,
|
|
9
|
+
markToolCallFinished,
|
|
10
|
+
updateSessionRelationship,
|
|
11
|
+
updateSessionRelationshipFromEvent,
|
|
12
|
+
isDescendantSession,
|
|
13
|
+
hasBusyDescendant,
|
|
14
|
+
refreshSessionRelationships,
|
|
15
|
+
updateToolActivityFromEvent,
|
|
16
|
+
clearSessionActivity,
|
|
17
|
+
} from "../src/source/runtime/session-activity.js"
|
|
18
|
+
|
|
19
|
+
const parentID = "ses_parent"
|
|
20
|
+
const childID = "ses_child"
|
|
21
|
+
const listedChildID = "ses_listed_child"
|
|
22
|
+
|
|
23
|
+
for (const sessionID of [parentID, childID, listedChildID]) clearSessionActivity(sessionID)
|
|
24
|
+
|
|
25
|
+
updateSessionRelationship({ id: childID, parentID })
|
|
26
|
+
assert.equal(sessionParents.get(childID), parentID)
|
|
27
|
+
assert.equal(isDescendantSession(childID, parentID), true)
|
|
28
|
+
assert.equal(isDescendantSession(parentID, childID), false)
|
|
29
|
+
|
|
30
|
+
markToolCallActive({ sessionID: childID, callID: "call_1" })
|
|
31
|
+
assert.equal(hasActiveToolCalls(childID), true)
|
|
32
|
+
assert.equal(sessionStatuses.get(childID), "busy")
|
|
33
|
+
assert.ok((sessionStatusSeenAt.get(childID) || 0) > 0)
|
|
34
|
+
assert.equal(hasBusyDescendant(parentID), true)
|
|
35
|
+
|
|
36
|
+
markToolCallFinished({ sessionID: childID, callID: "call_1" })
|
|
37
|
+
assert.equal(hasActiveToolCalls(childID), false)
|
|
38
|
+
sessionStatuses.set(childID, "idle")
|
|
39
|
+
assert.equal(hasBusyDescendant(parentID), false)
|
|
40
|
+
|
|
41
|
+
updateToolActivityFromEvent({
|
|
42
|
+
type: "message.part.updated",
|
|
43
|
+
properties: {
|
|
44
|
+
part: {
|
|
45
|
+
type: "tool",
|
|
46
|
+
sessionID: childID,
|
|
47
|
+
callID: "call_2",
|
|
48
|
+
id: "part_2",
|
|
49
|
+
state: { status: "running" },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
assert.equal(activeToolCalls.get(childID)?.has("call_2"), true)
|
|
54
|
+
|
|
55
|
+
updateToolActivityFromEvent({
|
|
56
|
+
type: "message.part.updated",
|
|
57
|
+
properties: {
|
|
58
|
+
part: {
|
|
59
|
+
type: "tool",
|
|
60
|
+
sessionID: childID,
|
|
61
|
+
callID: "call_2",
|
|
62
|
+
id: "part_2",
|
|
63
|
+
state: { status: "completed" },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
assert.equal(hasActiveToolCalls(childID), false)
|
|
68
|
+
|
|
69
|
+
await refreshSessionRelationships({
|
|
70
|
+
session: {
|
|
71
|
+
list: async () => [{ id: listedChildID, parentID }],
|
|
72
|
+
},
|
|
73
|
+
}, "/tmp/opencode-loop-session-activity")
|
|
74
|
+
assert.equal(sessionParents.get(listedChildID), parentID)
|
|
75
|
+
|
|
76
|
+
updateSessionRelationshipFromEvent({
|
|
77
|
+
type: "session.deleted",
|
|
78
|
+
properties: { info: { id: listedChildID, parentID } },
|
|
79
|
+
})
|
|
80
|
+
assert.equal(sessionParents.has(listedChildID), false)
|
|
81
|
+
|
|
82
|
+
sessionParents.set(parentID, childID)
|
|
83
|
+
sessionParents.set(childID, parentID)
|
|
84
|
+
assert.equal(isDescendantSession(parentID, "ses_missing"), false)
|
|
85
|
+
sessionParents.delete(parentID)
|
|
86
|
+
|
|
87
|
+
clearSessionActivity(childID)
|
|
88
|
+
assert.equal(activeToolCalls.has(childID), false)
|
|
89
|
+
assert.equal(sessionParents.has(childID), false)
|
|
90
|
+
assert.equal(sessionStatuses.has(childID), false)
|
|
91
|
+
assert.equal(sessionStatusSeenAt.has(childID), false)
|
|
92
|
+
clearSessionActivity(parentID)
|
|
93
|
+
clearSessionActivity(listedChildID)
|
|
94
|
+
|
|
95
|
+
console.log("session activity runtime tests passed")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createSessionRuntimeManager } from "../src/source/runtime/session-manager.js"
|
|
3
|
+
|
|
4
|
+
let clock = 1000
|
|
5
|
+
const cleared = []
|
|
6
|
+
const timerAPI = {
|
|
7
|
+
setTimeout(callback) { return { callback } },
|
|
8
|
+
clearTimeout(handle) { cleared.push(handle) },
|
|
9
|
+
setInterval(callback) { return { callback } },
|
|
10
|
+
clearInterval(handle) { cleared.push(handle) },
|
|
11
|
+
}
|
|
12
|
+
const manager = createSessionRuntimeManager({ now: () => clock, staleAfterMs: 100, timerAPI })
|
|
13
|
+
|
|
14
|
+
const first = manager.observeExternal("ses_a")
|
|
15
|
+
assert.equal(manager.entries()[0].seenAt, 1000)
|
|
16
|
+
clock = 1050
|
|
17
|
+
assert.equal(manager.peek("ses_a"), first)
|
|
18
|
+
assert.equal(manager.entries()[0].seenAt, 1000, "reads must not refresh external activity")
|
|
19
|
+
assert.equal(manager.observeExternal("ses_a"), first)
|
|
20
|
+
assert.equal(manager.entries()[0].seenAt, 1050)
|
|
21
|
+
|
|
22
|
+
first.dispose("replace")
|
|
23
|
+
clock = 1060
|
|
24
|
+
const second = manager.observeExternal("ses_a")
|
|
25
|
+
assert.notEqual(second, first)
|
|
26
|
+
assert.equal(manager.remove("ses_a", { expectedRuntime: first }), false)
|
|
27
|
+
assert.equal(manager.peek("ses_a"), second)
|
|
28
|
+
|
|
29
|
+
let calls = 0
|
|
30
|
+
const pending = second.timers.interval(() => ++calls, 10)
|
|
31
|
+
clock = 1159
|
|
32
|
+
assert.deepEqual(manager.pruneStale(), [])
|
|
33
|
+
clock = 1160
|
|
34
|
+
assert.deepEqual(manager.pruneStale(), ["ses_a"])
|
|
35
|
+
assert.equal(manager.peek("ses_a"), undefined)
|
|
36
|
+
assert.ok(cleared.includes(pending.handle))
|
|
37
|
+
pending.handle.callback()
|
|
38
|
+
assert.equal(calls, 0)
|
|
39
|
+
|
|
40
|
+
const third = manager.observeExternal("ses_a")
|
|
41
|
+
assert.notEqual(third, second)
|
|
42
|
+
assert.equal(manager.remove("ses_a", { expectedRuntime: third, reason: "done" }), true)
|
|
43
|
+
assert.equal(third.scope.isActive(), false)
|
|
44
|
+
assert.equal(manager.dispose(), true)
|
|
45
|
+
assert.equal(manager.dispose(), false)
|
|
46
|
+
assert.throws(() => manager.observeExternal("ses_b"), /disposed/)
|
|
47
|
+
|
|
48
|
+
console.log("OpenCode session runtime manager test passed")
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {
|
|
3
|
+
clearSessionActivity,
|
|
4
|
+
markToolCallActive,
|
|
5
|
+
markToolCallFinished,
|
|
6
|
+
sessionParents,
|
|
7
|
+
sessionStatuses,
|
|
8
|
+
sessionStatusSeenAt,
|
|
9
|
+
updateSessionRelationship,
|
|
10
|
+
} from "../src/source/runtime/session-activity.js"
|
|
11
|
+
import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
|
|
12
|
+
|
|
13
|
+
assert.throws(() => createSessionStatusRuntime({}), /activeRuns Map/)
|
|
14
|
+
|
|
15
|
+
const activeRuns = new Map()
|
|
16
|
+
const logs = []
|
|
17
|
+
let clock = 100_000
|
|
18
|
+
let completion = "unknown"
|
|
19
|
+
const runtime = createSessionStatusRuntime({
|
|
20
|
+
activeRuns,
|
|
21
|
+
now: () => clock,
|
|
22
|
+
sessionStatusCacheMs: 0,
|
|
23
|
+
appendLoopLog: async (...args) => { logs.push(args) },
|
|
24
|
+
activeRunCompletionFromMessages: async () => completion,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const ids = new Set()
|
|
28
|
+
const track = (...values) => {
|
|
29
|
+
for (const value of values) if (typeof value === "string") ids.add(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
{
|
|
34
|
+
const sessionID = "event-idle"
|
|
35
|
+
track(sessionID)
|
|
36
|
+
const result = runtime.updateSessionStatusFromEvent({ type: "session.idle", properties: { sessionID } })
|
|
37
|
+
assert.deepEqual(result, { sessionID, idle: true })
|
|
38
|
+
assert.equal(sessionStatuses.get(sessionID), "idle")
|
|
39
|
+
assert.equal(sessionStatusSeenAt.get(sessionID), clock)
|
|
40
|
+
|
|
41
|
+
clock++
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
runtime.updateSessionStatusFromEvent({ type: "session.status", properties: { sessionID, status: { type: "retry" } } }),
|
|
44
|
+
{ sessionID, idle: false },
|
|
45
|
+
)
|
|
46
|
+
assert.equal(sessionStatuses.get(sessionID), "retry")
|
|
47
|
+
assert.equal(sessionStatusSeenAt.get(sessionID), clock)
|
|
48
|
+
assert.equal(runtime.updateSessionStatusFromEvent({ type: "message.updated", properties: { sessionID } }), undefined)
|
|
49
|
+
assert.equal(runtime.updateSessionStatusFromEvent({ type: "session.idle", properties: {} }), undefined)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
const sessionID = "mark-clear"
|
|
54
|
+
track(sessionID)
|
|
55
|
+
assert.equal(runtime.markSessionStatus(sessionID, "busy", 1234), true)
|
|
56
|
+
assert.equal(sessionStatuses.get(sessionID), "busy")
|
|
57
|
+
assert.equal(sessionStatusSeenAt.get(sessionID), 1234)
|
|
58
|
+
assert.equal(runtime.markSessionStatus(undefined, "busy"), false)
|
|
59
|
+
runtime.clearSessionStatus(sessionID)
|
|
60
|
+
assert.equal(sessionStatuses.has(sessionID), false)
|
|
61
|
+
assert.equal(sessionStatusSeenAt.has(sessionID), false)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
const sessionID = "sdk-data-wrapper"
|
|
66
|
+
track(sessionID)
|
|
67
|
+
const calls = []
|
|
68
|
+
const client = {
|
|
69
|
+
session: {
|
|
70
|
+
status: async (args) => {
|
|
71
|
+
calls.push(args)
|
|
72
|
+
return { data: { [sessionID]: { type: "busy" } } }
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
const live = await runtime.readLiveSessionStatus(client, sessionID, "/repo")
|
|
77
|
+
assert.deepEqual(live, { type: "busy", source: "sdk" })
|
|
78
|
+
assert.deepEqual(calls, [{ query: { directory: "/repo" } }])
|
|
79
|
+
assert.equal(sessionStatuses.get(sessionID), "busy")
|
|
80
|
+
assert.equal(sessionStatusSeenAt.get(sessionID), clock)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
const sessionID = "sdk-error-fallback"
|
|
85
|
+
track(sessionID)
|
|
86
|
+
const calls = []
|
|
87
|
+
const client = {
|
|
88
|
+
session: {
|
|
89
|
+
status: async (args) => {
|
|
90
|
+
calls.push(args)
|
|
91
|
+
if (calls.length === 1) return { error: { message: "unsupported query shape" } }
|
|
92
|
+
return { data: { [sessionID]: { type: "idle" } } }
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
const live = await runtime.readLiveSessionStatus(client, sessionID, "/repo")
|
|
97
|
+
assert.deepEqual(live, { type: "idle", source: "sdk" })
|
|
98
|
+
assert.equal(calls.length, 2)
|
|
99
|
+
assert.deepEqual(calls[0], { query: { directory: "/repo" } })
|
|
100
|
+
assert.deepEqual(calls[1], { directory: "/repo" })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
const parentID = "parent-busy-child"
|
|
105
|
+
const childID = "child-busy"
|
|
106
|
+
track(parentID, childID)
|
|
107
|
+
updateSessionRelationship({ id: childID, parentID })
|
|
108
|
+
const client = {
|
|
109
|
+
session: {
|
|
110
|
+
status: async () => ({ data: { [childID]: { type: "busy" } } }),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
const live = await runtime.readLiveSessionStatus(client, parentID, "/repo")
|
|
114
|
+
assert.deepEqual(live, { type: "busy", source: "descendant" })
|
|
115
|
+
assert.equal(sessionParents.get(childID), parentID)
|
|
116
|
+
assert.equal(sessionStatuses.get(childID), "busy")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
const parentID = "parent-finished-child"
|
|
121
|
+
const childID = "child-finished"
|
|
122
|
+
track(parentID, childID)
|
|
123
|
+
updateSessionRelationship({ id: childID, parentID })
|
|
124
|
+
runtime.markSessionStatus(childID, "busy")
|
|
125
|
+
const client = {
|
|
126
|
+
session: {
|
|
127
|
+
status: async () => ({ data: { [parentID]: { type: "idle" } } }),
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
const live = await runtime.readLiveSessionStatus(client, parentID, "/repo")
|
|
131
|
+
assert.deepEqual(live, { type: "idle", source: "sdk" })
|
|
132
|
+
assert.equal(sessionStatuses.get(childID), "idle", "omitted descendant must be cleared to idle")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
const sessionID = "active-tool"
|
|
137
|
+
track(sessionID)
|
|
138
|
+
runtime.clearSessionStatus(sessionID)
|
|
139
|
+
markToolCallActive({ sessionID, callID: "tool-1" })
|
|
140
|
+
const client = { session: { status: async () => ({ data: { [sessionID]: { type: "idle" } } }) } }
|
|
141
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy")
|
|
142
|
+
assert.equal(sessionStatuses.get(sessionID), "busy")
|
|
143
|
+
markToolCallFinished({ sessionID, callID: "tool-1" })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
const sessionID = "message-complete-recovery"
|
|
148
|
+
track(sessionID)
|
|
149
|
+
runtime.clearSessionStatus(sessionID)
|
|
150
|
+
activeRuns.set(sessionID, { jobId: "job-1", job: { id: "job-1", name: "dev" }, startedAt: 1_000 })
|
|
151
|
+
completion = "completed"
|
|
152
|
+
const client = { session: { status: async () => ({ data: { [sessionID]: { type: "busy" } } }) } }
|
|
153
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
|
|
154
|
+
assert.equal(sessionStatuses.get(sessionID), "idle")
|
|
155
|
+
assert.deepEqual(logs.at(-1), ["/repo", "status-message-complete-recovery", { sessionID, job: "dev", startedAt: 1_000 }])
|
|
156
|
+
activeRuns.delete(sessionID)
|
|
157
|
+
completion = "unknown"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
const sessionID = "stale-recovery"
|
|
162
|
+
track(sessionID)
|
|
163
|
+
runtime.clearSessionStatus(sessionID)
|
|
164
|
+
activeRuns.set(sessionID, { jobId: "job-2", job: { id: "job-2" }, startedAt: 1_000 })
|
|
165
|
+
completion = "unknown"
|
|
166
|
+
const client = { session: { status: async () => ({ data: { [sessionID]: { type: "retry" } } }) } }
|
|
167
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
|
|
168
|
+
assert.deepEqual(logs.at(-1), ["/repo", "status-stale-recovery", { sessionID, job: "job-2", startedAt: 1_000 }])
|
|
169
|
+
activeRuns.delete(sessionID)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
const sessionID = "fallback-fresh"
|
|
174
|
+
track(sessionID)
|
|
175
|
+
runtime.clearSessionStatus(sessionID)
|
|
176
|
+
activeRuns.set(sessionID, { jobId: "fresh", job: {}, startedAt: clock - 1_000 })
|
|
177
|
+
const client = { session: { status: async () => { throw new Error("offline") } } }
|
|
178
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy")
|
|
179
|
+
activeRuns.set(sessionID, { jobId: "stale", job: {}, startedAt: clock - 60_000 })
|
|
180
|
+
runtime.clearSessionStatus(sessionID)
|
|
181
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
|
|
182
|
+
activeRuns.delete(sessionID)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
{
|
|
186
|
+
const sessionID = "finalize-basic"
|
|
187
|
+
track(sessionID)
|
|
188
|
+
runtime.clearSessionStatus(sessionID)
|
|
189
|
+
const active = { jobId: "job", job: {}, startedAt: clock - 100 }
|
|
190
|
+
assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
|
|
191
|
+
|
|
192
|
+
markToolCallActive({ sessionID, callID: "tool-finalize" })
|
|
193
|
+
assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), false)
|
|
194
|
+
markToolCallFinished({ sessionID, callID: "tool-finalize" })
|
|
195
|
+
|
|
196
|
+
runtime.markSessionStatus(sessionID, "idle", clock + 1)
|
|
197
|
+
const client = { session: { status: async () => { throw new Error("status unavailable") } } }
|
|
198
|
+
assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true }), true)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
const sessionID = "force-stale"
|
|
203
|
+
track(sessionID)
|
|
204
|
+
runtime.clearSessionStatus(sessionID)
|
|
205
|
+
activeRuns.set(sessionID, { jobId: "force", job: {}, startedAt: clock - 60_000 })
|
|
206
|
+
completion = "unknown"
|
|
207
|
+
const active = activeRuns.get(sessionID)
|
|
208
|
+
const client = { session: { status: async () => ({ data: { [sessionID]: { type: "busy" } } }) } }
|
|
209
|
+
assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true, forceStale: true }), true)
|
|
210
|
+
activeRuns.delete(sessionID)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
assert.equal(await runtime.readLiveSessionStatus({}, "missing-client", "/repo"), undefined)
|
|
214
|
+
assert.equal(await runtime.sessionIsIdle({ session: { status: async () => ({ data: {} }) } }, "unlisted", "/repo"), true)
|
|
215
|
+
|
|
216
|
+
console.log("session status tests passed")
|
|
217
|
+
} finally {
|
|
218
|
+
activeRuns.clear()
|
|
219
|
+
for (const sessionID of ids) clearSessionActivity(sessionID)
|
|
220
|
+
}
|