@bybrawe/opencode-loop 0.5.37 → 0.6.0
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 +26 -0
- package/README.md +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +105 -58
- package/scripts/install-test.mjs +6 -1
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +107 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +42 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +14 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- package/src/v1.js +1 -0
|
@@ -2,24 +2,70 @@ import assert from "node:assert/strict"
|
|
|
2
2
|
import { readFile } from "node:fs/promises"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { validateRelease, releaseDecision, assertPublishedSource, waitForPublishedGoal } from "./native-release.mjs"
|
|
5
6
|
|
|
6
7
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
assert.match(workflow, /
|
|
11
|
-
assert.
|
|
12
|
-
assert.match(workflow, /
|
|
13
|
-
assert.match(workflow, /
|
|
14
|
-
assert.match(workflow, /
|
|
15
|
-
assert.match(workflow, /
|
|
8
|
+
const workflow = await readFile(path.join(repoRoot, ".github/workflows/publish-npm.yml"), "utf8")
|
|
9
|
+
assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "checkout must use the immutable trigger SHA")
|
|
10
|
+
assert.doesNotMatch(workflow, /ref:\s*main\b/, "publication cannot checkout moving main")
|
|
11
|
+
assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/)
|
|
12
|
+
assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/)
|
|
13
|
+
assert.match(workflow, /group:\s*npm-release/)
|
|
14
|
+
assert.match(workflow, /cancel-in-progress:\s*false/)
|
|
15
|
+
assert.match(workflow, /branches:\s*\[main\]/)
|
|
16
|
+
assert.match(workflow, /paths:\s*\[\.github\/npm-release\.json\]/)
|
|
17
|
+
assert.doesNotMatch(workflow, /release\/npm-v|git push|Remove one-shot release branch/, "main-only publication must not create or delete branches")
|
|
18
|
+
assert.match(workflow, /needs:\s*\[native-regressions, real-native-pair\]/)
|
|
19
|
+
assert.match(workflow, /node scripts\/native-release\.mjs ci/)
|
|
20
|
+
assert.match(workflow, /git diff --exit-code -- src\/index.js src\/server.js src\/native.js/)
|
|
21
|
+
assert.match(workflow, /npm run package:smoke/)
|
|
22
|
+
assert.match(workflow, /windows-latest/)
|
|
23
|
+
assert.match(workflow, /Decide whether npm publish is needed/)
|
|
24
|
+
assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/)
|
|
25
|
+
assert.match(workflow, /for attempt in \{1\.\.60\}/)
|
|
26
|
+
assert.match(workflow, /--registry=https:\/\/registry\.npmjs\.org/)
|
|
27
|
+
assert.match(workflow, /native-release\.mjs verify/)
|
|
28
|
+
assert.match(workflow, /node_modules\/@bybrawe\/opencode-loop\/scripts\/opencode2-loop-canary.mjs/)
|
|
29
|
+
assert.match(workflow, /node_modules\/@bybrawe\/opencode-goal\/dist\/server.js/)
|
|
30
|
+
assert.match(workflow, /ref: \$\{\{ steps.release.outputs.goal_sha \}\}/)
|
|
31
|
+
assert.doesNotMatch(workflow, /secrets\.NPM_TOKEN/)
|
|
16
32
|
|
|
17
33
|
const publishIndex = workflow.indexOf("npm publish --access public")
|
|
18
34
|
const verifyIndex = workflow.indexOf("Verify published version is readable from npm")
|
|
19
35
|
const tagIndex = workflow.indexOf("Create tag and GitHub release")
|
|
20
|
-
|
|
21
|
-
assert.ok(
|
|
22
|
-
assert.
|
|
23
|
-
|
|
36
|
+
assert.ok(publishIndex >= 0 && verifyIndex > publishIndex)
|
|
37
|
+
assert.ok(tagIndex > verifyIndex)
|
|
38
|
+
assert.match(workflow.slice(tagIndex), /needs:\s*publish/, "GitHub release must depend on verified publication")
|
|
39
|
+
for (const block of workflow.split(/- uses: actions\/checkout@/).slice(1)) assert.match(block.split(/\n\s*- (?:uses|name|run):/)[0], /persist-credentials:\s*false/)
|
|
40
|
+
const publisher = workflow.slice(workflow.indexOf(" publish:\n"), workflow.indexOf(" github-release:\n"))
|
|
41
|
+
assert.doesNotMatch(publisher, /contents:\s*write/, "OIDC publisher must not gain repository mutation privileges")
|
|
42
|
+
|
|
43
|
+
const sha = "a".repeat(40)
|
|
44
|
+
const request = { name: "@bybrawe/opencode-loop", version: "0.6.0", predecessor: "0.5.38", goal: { version: "1.3.39", sha: "b".repeat(40) } }
|
|
45
|
+
const pkg = { name: request.name, version: request.version }
|
|
46
|
+
const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
|
|
47
|
+
assert.strictEqual(validateRelease(request, pkg, env), request)
|
|
48
|
+
for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
|
|
49
|
+
assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
|
|
50
|
+
assert.throws(() => validateRelease(request, { ...pkg, version: "0.5.38" }, env))
|
|
51
|
+
assert.equal(releaseDecision(404, null, request, sha, "0.5.38"), true)
|
|
52
|
+
for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.5.38"))
|
|
53
|
+
assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
|
|
54
|
+
const manifest = { ...pkg, gitHead: sha }
|
|
55
|
+
assert.equal(releaseDecision(200, manifest, request, sha), false)
|
|
56
|
+
assert.throws(() => releaseDecision(200, { ...manifest, gitHead: "b".repeat(40) }, request, sha))
|
|
57
|
+
assert.throws(() => assertPublishedSource({ ...manifest, gitHead: undefined }, pkg.name, pkg.version, sha))
|
|
24
58
|
|
|
25
|
-
|
|
59
|
+
let calls = 0, sleeps = 0
|
|
60
|
+
const goalManifest = { name: "@bybrawe/opencode-goal", version: request.goal.version, gitHead: request.goal.sha }
|
|
61
|
+
const recovered = await waitForPublishedGoal(request.goal, {
|
|
62
|
+
attempts: 2, sleep: async () => { sleeps++ },
|
|
63
|
+
request: async () => ++calls === 1 ? { ok: false, status: 404 } : { ok: true, json: async () => goalManifest },
|
|
64
|
+
})
|
|
65
|
+
assert.deepEqual(recovered, goalManifest)
|
|
66
|
+
assert.equal(calls, 2)
|
|
67
|
+
assert.equal(sleeps, 1)
|
|
68
|
+
await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: false, status: 404 }) }), /not published/)
|
|
69
|
+
for (const status of [403, 429, 503]) await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: false, status }) }), /transport\/auth/)
|
|
70
|
+
await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: true, json: async () => ({ ...goalManifest, gitHead: sha }) }) }), /different source/)
|
|
71
|
+
console.log("main-only publish workflow, source identity and registry failure regressions passed")
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { dedicatedGoalOwnsContinuation } from "../src/source/runtime/companion-goal.js"
|
|
4
|
+
|
|
5
|
+
const LOCK_STALE_MS = 30_000
|
|
6
|
+
const LOCK_RETRY_MS = 25
|
|
7
|
+
const LOCK_RETRIES = 80
|
|
8
|
+
|
|
9
|
+
function sleep(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parsePulseDurationMs(value) {
|
|
14
|
+
const text = String(value ?? "").trim().toLowerCase()
|
|
15
|
+
if (text === "0" || text === "0s" || text === "now") return 0
|
|
16
|
+
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/)
|
|
17
|
+
if (!match) throw new Error(`Invalid duration: ${value}`)
|
|
18
|
+
const amount = Number(match[1])
|
|
19
|
+
const unit = match[2]
|
|
20
|
+
if (unit === "ms") return amount
|
|
21
|
+
if (unit.startsWith("s")) return amount * 1000
|
|
22
|
+
if (unit.startsWith("m")) return amount * 60_000
|
|
23
|
+
if (unit.startsWith("h")) return amount * 3_600_000
|
|
24
|
+
if (unit.startsWith("d")) return amount * 86_400_000
|
|
25
|
+
return amount
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pulseDirectory(project) {
|
|
29
|
+
return path.join(project, ".opencode", "opencode-loop")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pulseStateFile(project) {
|
|
33
|
+
return path.join(pulseDirectory(project), "pulse-check.json")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pulseLockDirectory(project) {
|
|
37
|
+
return path.join(pulseDirectory(project), "pulse-check.lock")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function goalDirectory(project) {
|
|
41
|
+
return path.join(project, ".opencode", "goals")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function timestamp(value) {
|
|
45
|
+
const parsed = Number(value)
|
|
46
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function revision(value) {
|
|
50
|
+
const parsed = Number(value)
|
|
51
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function goalFingerprint(goal) {
|
|
55
|
+
return [
|
|
56
|
+
String(goal?.status || "unknown"),
|
|
57
|
+
timestamp(goal?.updatedAt) ?? 0,
|
|
58
|
+
revision(goal?.progressRevision),
|
|
59
|
+
revision(goal?.revision),
|
|
60
|
+
revision(goal?.storageGeneration),
|
|
61
|
+
].join(":")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatAge(ms) {
|
|
65
|
+
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s`
|
|
66
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
|
|
67
|
+
if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(ms < 36_000_000 ? 1 : 0)}h`
|
|
68
|
+
return `${(ms / 86_400_000).toFixed(ms < 864_000_000 ? 1 : 0)}d`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function iso(value) {
|
|
72
|
+
const parsed = timestamp(value)
|
|
73
|
+
if (parsed === undefined) return "unknown"
|
|
74
|
+
try { return new Date(parsed).toISOString() } catch { return "unknown" }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readJson(file, fallback) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(await fs.readFile(file, "utf8"))
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error?.code === "ENOENT") return fallback
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeJsonAtomic(file, value) {
|
|
87
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
88
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
89
|
+
await fs.writeFile(temporary, JSON.stringify(value, null, 2) + "\n", "utf8")
|
|
90
|
+
try {
|
|
91
|
+
await fs.rename(temporary, file)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
try { await fs.rm(temporary, { force: true }) } catch {}
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function acquireLock(project) {
|
|
99
|
+
const directory = pulseDirectory(project)
|
|
100
|
+
const lock = pulseLockDirectory(project)
|
|
101
|
+
await fs.mkdir(directory, { recursive: true })
|
|
102
|
+
|
|
103
|
+
for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) {
|
|
104
|
+
try {
|
|
105
|
+
await fs.mkdir(lock)
|
|
106
|
+
return async () => {
|
|
107
|
+
try { await fs.rm(lock, { recursive: true, force: true }) } catch {}
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error?.code !== "EEXIST") throw error
|
|
111
|
+
try {
|
|
112
|
+
const stat = await fs.stat(lock)
|
|
113
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
114
|
+
await fs.rm(lock, { recursive: true, force: true })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
} catch (statError) {
|
|
118
|
+
if (statError?.code !== "ENOENT") throw statError
|
|
119
|
+
}
|
|
120
|
+
await sleep(LOCK_RETRY_MS)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error("Goal pulse-check state is busy; another checker still holds the lock")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readGoalSnapshots(project, target) {
|
|
128
|
+
let names
|
|
129
|
+
try {
|
|
130
|
+
names = await fs.readdir(goalDirectory(project))
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.code === "ENOENT") return []
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const goals = []
|
|
137
|
+
for (const name of names.sort()) {
|
|
138
|
+
if (!name.endsWith(".json")) continue
|
|
139
|
+
try {
|
|
140
|
+
const goal = JSON.parse(await fs.readFile(path.join(goalDirectory(project), name), "utf8"))
|
|
141
|
+
if (!goal?.id || !goal?.sessionID) continue
|
|
142
|
+
if (target && goal.id !== target && goal.sessionID !== target) continue
|
|
143
|
+
goals.push(goal)
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
return goals
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function checkGoalPulse(project, options = {}) {
|
|
150
|
+
const staleAfterMs = Number(options.staleAfterMs)
|
|
151
|
+
if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) throw new Error("staleAfterMs must be a non-negative number")
|
|
152
|
+
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now()
|
|
153
|
+
const target = options.target ? String(options.target) : undefined
|
|
154
|
+
const sessionActivity = options.sessionActivity && typeof options.sessionActivity === "object" ? options.sessionActivity : {}
|
|
155
|
+
const release = await acquireLock(project)
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const goals = await readGoalSnapshots(project, target)
|
|
159
|
+
const stateFile = pulseStateFile(project)
|
|
160
|
+
const state = await readJson(stateFile, { schemaVersion: 1, goals: {} })
|
|
161
|
+
if (!state.goals || typeof state.goals !== "object") state.goals = {}
|
|
162
|
+
|
|
163
|
+
const alerts = []
|
|
164
|
+
for (const goal of goals) {
|
|
165
|
+
const id = String(goal.id)
|
|
166
|
+
const status = String(goal.status || "unknown")
|
|
167
|
+
const progressRevision = revision(goal.progressRevision)
|
|
168
|
+
const goalUpdatedAt = timestamp(goal.updatedAt) ?? now
|
|
169
|
+
const fingerprint = goalFingerprint(goal)
|
|
170
|
+
const previous = state.goals[id]
|
|
171
|
+
const changed = !previous || previous.fingerprint !== fingerprint
|
|
172
|
+
const statusChanged = Boolean(previous && previous.status !== status)
|
|
173
|
+
const progressChanged = Boolean(previous && Number(previous.progressRevision || 0) !== progressRevision)
|
|
174
|
+
const updatedAdvanced = Boolean(previous && goalUpdatedAt > Number(previous.goalUpdatedAt || 0))
|
|
175
|
+
|
|
176
|
+
let lastActivityAt
|
|
177
|
+
if (!previous) {
|
|
178
|
+
lastActivityAt = goalUpdatedAt
|
|
179
|
+
} else if (!changed) {
|
|
180
|
+
lastActivityAt = Number(previous.lastActivityAt || goalUpdatedAt)
|
|
181
|
+
} else if (updatedAdvanced) {
|
|
182
|
+
lastActivityAt = goalUpdatedAt
|
|
183
|
+
} else if (statusChanged || progressChanged || Number(previous.revision || 0) !== revision(goal.revision)) {
|
|
184
|
+
lastActivityAt = now
|
|
185
|
+
} else {
|
|
186
|
+
lastActivityAt = Math.max(Number(previous.lastActivityAt || 0), goalUpdatedAt)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let alerted = changed ? false : Boolean(previous?.alerted)
|
|
190
|
+
let newAlert = false
|
|
191
|
+
const active = dedicatedGoalOwnsContinuation(goal)
|
|
192
|
+
const ageMs = Math.max(0, now - lastActivityAt)
|
|
193
|
+
const sessionUpdatedAt = timestamp(sessionActivity[goal.sessionID])
|
|
194
|
+
|
|
195
|
+
if (!active) {
|
|
196
|
+
alerted = false
|
|
197
|
+
} else if (ageMs >= staleAfterMs && !alerted) {
|
|
198
|
+
alerted = true
|
|
199
|
+
newAlert = true
|
|
200
|
+
const message = [
|
|
201
|
+
"[opencode-loopd] GOAL PULSE STALLED",
|
|
202
|
+
`goal=${id}`,
|
|
203
|
+
`session=${goal.sessionID}`,
|
|
204
|
+
`stale=${formatAge(ageMs)}`,
|
|
205
|
+
`goalUpdated=${iso(goalUpdatedAt)}`,
|
|
206
|
+
sessionUpdatedAt === undefined ? undefined : `sessionUpdated=${iso(sessionUpdatedAt)}`,
|
|
207
|
+
'action="/goal resume"',
|
|
208
|
+
].filter(Boolean).join(" ")
|
|
209
|
+
alerts.push({
|
|
210
|
+
goalID: id,
|
|
211
|
+
sessionID: String(goal.sessionID),
|
|
212
|
+
ageMs,
|
|
213
|
+
goalUpdatedAt,
|
|
214
|
+
sessionUpdatedAt,
|
|
215
|
+
progressRevision,
|
|
216
|
+
message,
|
|
217
|
+
})
|
|
218
|
+
} else if (ageMs < staleAfterMs) {
|
|
219
|
+
alerted = false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
state.goals[id] = {
|
|
223
|
+
fingerprint,
|
|
224
|
+
status,
|
|
225
|
+
progressRevision,
|
|
226
|
+
revision: revision(goal.revision),
|
|
227
|
+
goalUpdatedAt,
|
|
228
|
+
lastActivityAt,
|
|
229
|
+
alerted,
|
|
230
|
+
...(alerted ? { lastAlertAt: newAlert ? now : previous?.lastAlertAt } : {}),
|
|
231
|
+
sessionID: String(goal.sessionID),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
state.schemaVersion = 1
|
|
236
|
+
state.updatedAt = now
|
|
237
|
+
await writeJsonAtomic(stateFile, state)
|
|
238
|
+
return {
|
|
239
|
+
alerts,
|
|
240
|
+
checked: goals.length,
|
|
241
|
+
stateFile,
|
|
242
|
+
}
|
|
243
|
+
} finally {
|
|
244
|
+
await release()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -98,6 +98,39 @@ assert.equal(failedPostrun.failureCount, 1)
|
|
|
98
98
|
assert.equal(failedPostrun.paused, true)
|
|
99
99
|
assert.equal(notifications.at(-1)[2], "postrun_failed")
|
|
100
100
|
|
|
101
|
+
const waitingNotifications = []
|
|
102
|
+
const waitingLogs = []
|
|
103
|
+
const waitingToasts = []
|
|
104
|
+
const waitingRuntime = createRunFinalizationRuntime({
|
|
105
|
+
runGoalChecks: async (_directory, _sessionID, job) => job,
|
|
106
|
+
applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
|
|
107
|
+
createCheckpoint: async () => {},
|
|
108
|
+
scheduleDueWork: async () => {},
|
|
109
|
+
writeState: async () => {},
|
|
110
|
+
appendLoopLog: async (...args) => { waitingLogs.push(args) },
|
|
111
|
+
notifyJob: async (...args) => { waitingNotifications.push(args) },
|
|
112
|
+
toast: async (...args) => { waitingToasts.push(args) },
|
|
113
|
+
applyTerminalContinuationGuard: async (_directory, _client, _sessionID, job) => ({
|
|
114
|
+
job: {
|
|
115
|
+
...job,
|
|
116
|
+
paused: true,
|
|
117
|
+
waitingUserCount: 2,
|
|
118
|
+
lastFailureReason: "waiting_user",
|
|
119
|
+
},
|
|
120
|
+
terminal: false,
|
|
121
|
+
pausedNow: false,
|
|
122
|
+
waitingUser: true,
|
|
123
|
+
waitingUserPausedNow: true,
|
|
124
|
+
text: "Onay bekleyenler: commit + push, canlı migration, cihaz testi.",
|
|
125
|
+
}),
|
|
126
|
+
})
|
|
127
|
+
const waitingJob = { id: "waiting-user", name: "default", enabled: true, paused: false }
|
|
128
|
+
await waitingRuntime.finalizeJob("/repo", {}, "session-waiting", { jobs: [waitingJob] }, waitingJob, { id: "before-waiting" })
|
|
129
|
+
assert.equal(waitingNotifications.at(-1)[2], "waiting_user")
|
|
130
|
+
assert.equal(waitingLogs.at(-1)[1], "waiting-user")
|
|
131
|
+
assert.match(waitingToasts.at(-1)[1], /waiting for explicit user approval/)
|
|
132
|
+
assert.equal(waitingToasts.at(-1)[2], "warning")
|
|
133
|
+
|
|
101
134
|
const goal = {
|
|
102
135
|
id: "goal-job",
|
|
103
136
|
kind: "goal",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import plugin, {
|
|
3
|
+
OPENCODE_LOOP_PLUGIN_ID,
|
|
4
|
+
OpenCodeLoopPlugin,
|
|
5
|
+
OpenCodeLoopV2ExperimentalPlugin,
|
|
6
|
+
} from "../src/source/server.js"
|
|
7
|
+
|
|
8
|
+
assert.equal(plugin.id, OPENCODE_LOOP_PLUGIN_ID)
|
|
9
|
+
assert.equal(plugin.id, "@bybrawe/opencode-loop")
|
|
10
|
+
assert.equal(typeof plugin.server, "function", "OpenCode 1.x requires default.server()")
|
|
11
|
+
assert.equal(typeof plugin.setup, "function", "OpenCode 2.x requires default.setup()")
|
|
12
|
+
assert.strictEqual(plugin.server, OpenCodeLoopPlugin)
|
|
13
|
+
assert.equal(typeof OpenCodeLoopV2ExperimentalPlugin.setup, "function")
|
|
14
|
+
|
|
15
|
+
console.log("dual OpenCode 1/2 server entry contract passed")
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createLoopExecutor } from "../src/source/runtime/executor.js"
|
|
3
|
+
import { clearSessionActivity } from "../src/source/runtime/session-activity.js"
|
|
4
|
+
|
|
5
|
+
function clone(value) {
|
|
6
|
+
return JSON.parse(JSON.stringify(value))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let clock = 1_000_000
|
|
10
|
+
const directory = "/repo"
|
|
11
|
+
const states = new Map()
|
|
12
|
+
const logs = []
|
|
13
|
+
const toasts = []
|
|
14
|
+
const schedules = []
|
|
15
|
+
const notifications = []
|
|
16
|
+
|
|
17
|
+
function stateKey(sessionID) {
|
|
18
|
+
return `${directory}:${sessionID}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setJob(sessionID, overrides = {}) {
|
|
22
|
+
states.set(stateKey(sessionID), {
|
|
23
|
+
jobs: [{
|
|
24
|
+
id: `${sessionID}-job`,
|
|
25
|
+
name: `${sessionID}-job`,
|
|
26
|
+
action: "devam et",
|
|
27
|
+
enabled: true,
|
|
28
|
+
paused: false,
|
|
29
|
+
intervalMs: 0,
|
|
30
|
+
runCount: 0,
|
|
31
|
+
maxRuns: 0,
|
|
32
|
+
maxRuntimeMs: 0,
|
|
33
|
+
timeoutMs: 0,
|
|
34
|
+
noOverlap: true,
|
|
35
|
+
...overrides,
|
|
36
|
+
}],
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const executor = createLoopExecutor({
|
|
41
|
+
workspace: {
|
|
42
|
+
buildPrompt: async (_directory, job) => job.action,
|
|
43
|
+
ensureBranch: async (_directory, job) => job,
|
|
44
|
+
watchChanged: async () => false,
|
|
45
|
+
untilReached: async () => false,
|
|
46
|
+
createCheckpoint: async () => {},
|
|
47
|
+
},
|
|
48
|
+
goalPolicy: {
|
|
49
|
+
runGoalChecks: async (_directory, _sessionID, job) => job,
|
|
50
|
+
applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
|
|
51
|
+
},
|
|
52
|
+
scheduler: {
|
|
53
|
+
rememberSession: () => {},
|
|
54
|
+
scheduleDueWork: async (...args) => schedules.push(args),
|
|
55
|
+
},
|
|
56
|
+
now: () => clock,
|
|
57
|
+
readState: async (_directory, sessionID) => clone(states.get(stateKey(sessionID)) || { jobs: [] }),
|
|
58
|
+
writeState: async (_directory, sessionID, state) => states.set(stateKey(sessionID), clone(state)),
|
|
59
|
+
appendLoopLog: async (...args) => logs.push(args),
|
|
60
|
+
notifyJob: async (...args) => notifications.push(args),
|
|
61
|
+
toast: async (...args) => toasts.push(args),
|
|
62
|
+
runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),
|
|
63
|
+
fireSdk: async () => ({ data: {} }),
|
|
64
|
+
sdkCall: async () => ({}),
|
|
65
|
+
normalizedModelRef: () => undefined,
|
|
66
|
+
compactTuiCommandName: () => undefined,
|
|
67
|
+
guardLoopOwnedUserMessage: () => {},
|
|
68
|
+
dangerousShell: () => false,
|
|
69
|
+
activeRunCompletionFromMessages: async () => "unknown",
|
|
70
|
+
busyRetryMs: 5_000,
|
|
71
|
+
networkRetryMaxMs: 60_000,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const client = {
|
|
75
|
+
session: {
|
|
76
|
+
status: async () => ({ data: {} }),
|
|
77
|
+
prompt: async () => ({ data: {} }),
|
|
78
|
+
abort: async () => ({ data: {} }),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
const sessionID = "policy-error"
|
|
84
|
+
setJob(sessionID)
|
|
85
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
86
|
+
assert.ok(executor.getActiveRun(sessionID))
|
|
87
|
+
assert.equal(states.get(stateKey(sessionID)).jobs[0].runCount, 1)
|
|
88
|
+
|
|
89
|
+
const handled = await executor.handleSessionError(directory, client, {
|
|
90
|
+
type: "session.error",
|
|
91
|
+
properties: {
|
|
92
|
+
sessionID,
|
|
93
|
+
error: {
|
|
94
|
+
name: "APIError",
|
|
95
|
+
data: {
|
|
96
|
+
message: "OpenCode's free tier can only be used from within OpenCode",
|
|
97
|
+
statusCode: 403,
|
|
98
|
+
isRetryable: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
assert.equal(handled, true)
|
|
105
|
+
assert.equal(executor.getActiveRun(sessionID), undefined)
|
|
106
|
+
const job = states.get(stateKey(sessionID)).jobs[0]
|
|
107
|
+
assert.equal(job.paused, true, "terminal provider policy error must pause an infinite idle loop")
|
|
108
|
+
assert.equal(job.failureCount, 1)
|
|
109
|
+
assert.equal(job.lastFailureReason, "session_error")
|
|
110
|
+
assert.equal(job.lastSessionErrorName, "APIError")
|
|
111
|
+
assert.equal(job.lastSessionError, "OpenCode's free tier can only be used from within OpenCode")
|
|
112
|
+
assert.equal(job.lastSessionErrorAt, clock)
|
|
113
|
+
assert.ok(notifications.some((call) => call[1]?.id === job.id && call[2] === "session_error"))
|
|
114
|
+
assert.ok(logs.some((call) => call[1] === "session-error" && call[2]?.sessionID === sessionID && call[2]?.paused === true))
|
|
115
|
+
assert.ok(toasts.some((call) => String(call[1]).includes("Loop paused after a terminal OpenCode session error")))
|
|
116
|
+
|
|
117
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
118
|
+
assert.equal(states.get(stateKey(sessionID)).jobs[0].runCount, 1, "paused terminal-error job must not immediately dispatch another run")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
const sessionID = "retryable-provider-error"
|
|
123
|
+
setJob(sessionID, { maxRuns: 1 })
|
|
124
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
125
|
+
let job = states.get(stateKey(sessionID)).jobs[0]
|
|
126
|
+
assert.equal(job.runCount, 1)
|
|
127
|
+
assert.equal(job.enabled, false, "max-runs may disable while the provider turn is in flight")
|
|
128
|
+
|
|
129
|
+
clock += 10
|
|
130
|
+
const handled = await executor.handleSessionError(directory, client, {
|
|
131
|
+
type: "session.error",
|
|
132
|
+
properties: {
|
|
133
|
+
sessionID,
|
|
134
|
+
error: {
|
|
135
|
+
name: "APIError",
|
|
136
|
+
data: {
|
|
137
|
+
message: "provider temporarily unavailable",
|
|
138
|
+
statusCode: 503,
|
|
139
|
+
isRetryable: true,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
assert.equal(handled, true)
|
|
146
|
+
assert.equal(executor.getActiveRun(sessionID), undefined)
|
|
147
|
+
job = states.get(stateKey(sessionID)).jobs[0]
|
|
148
|
+
assert.equal(job.runCount, 0, "retryable terminal provider error must refund the logical run")
|
|
149
|
+
assert.equal(job.enabled, true, "refunded max-runs disable must be rolled back")
|
|
150
|
+
assert.equal(job.paused, false)
|
|
151
|
+
assert.equal(job.failureCount || 0, 0, "retryable provider outage must not consume ordinary failure budget")
|
|
152
|
+
assert.equal(job.infrastructureFailureCount, 1)
|
|
153
|
+
assert.equal(job.lastInfrastructureFailure, "session_error_retryable")
|
|
154
|
+
assert.match(job.lastInfrastructureError, /provider temporarily unavailable/)
|
|
155
|
+
assert.ok(logs.some((call) => call[1] === "provider-session-error-retry" && call[2]?.sessionID === sessionID))
|
|
156
|
+
assert.ok(schedules.some((call) => call[2] === sessionID && call[3] >= 5_000))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
const sessionID = "user-abort"
|
|
161
|
+
setJob(sessionID)
|
|
162
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
163
|
+
const active = executor.getActiveRun(sessionID)
|
|
164
|
+
assert.ok(active)
|
|
165
|
+
|
|
166
|
+
const handled = await executor.handleSessionError(directory, client, {
|
|
167
|
+
type: "session.error",
|
|
168
|
+
properties: {
|
|
169
|
+
sessionID,
|
|
170
|
+
error: {
|
|
171
|
+
name: "MessageAbortedError",
|
|
172
|
+
data: { message: "The operation was aborted." },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
assert.equal(handled, false, "user/session abort must keep existing abort/finalization semantics")
|
|
178
|
+
assert.strictEqual(executor.getActiveRun(sessionID), active)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const sessionID of ["policy-error", "retryable-provider-error", "user-abort"]) {
|
|
182
|
+
executor.disposeSession(sessionID)
|
|
183
|
+
clearSessionActivity(sessionID)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log("session error recovery tests passed")
|
|
@@ -157,6 +157,36 @@ try {
|
|
|
157
157
|
completion = "unknown"
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
{
|
|
161
|
+
const sessionID = "concurrent-recovery-log-dedupe"
|
|
162
|
+
track(sessionID)
|
|
163
|
+
runtime.clearSessionStatus(sessionID)
|
|
164
|
+
activeRuns.set(sessionID, { jobId: "job-dedupe", job: { id: "job-dedupe", name: "dedupe" }, startedAt: 1_000 })
|
|
165
|
+
completion = "completed"
|
|
166
|
+
let releaseStatus
|
|
167
|
+
const gate = new Promise((resolve) => { releaseStatus = resolve })
|
|
168
|
+
let calls = 0
|
|
169
|
+
const client = {
|
|
170
|
+
session: {
|
|
171
|
+
status: async () => {
|
|
172
|
+
calls += 1
|
|
173
|
+
await gate
|
|
174
|
+
return { data: { [sessionID]: { type: "busy" } } }
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
const before = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
|
|
179
|
+
const pending = Array.from({ length: 20 }, () => runtime.sessionStatusType(client, sessionID, "/repo"))
|
|
180
|
+
await Promise.resolve()
|
|
181
|
+
assert.equal(calls, 20, "all concurrent status reads should be in flight before release")
|
|
182
|
+
releaseStatus()
|
|
183
|
+
assert.deepEqual(await Promise.all(pending), Array(20).fill("idle"))
|
|
184
|
+
const after = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
|
|
185
|
+
assert.equal(after - before, 1, "one active run must emit at most one copy of the same stale-recovery diagnostic")
|
|
186
|
+
activeRuns.delete(sessionID)
|
|
187
|
+
completion = "unknown"
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
{
|
|
161
191
|
const sessionID = "retry-remains-owned"
|
|
162
192
|
track(sessionID)
|
|
@@ -201,6 +231,17 @@ try {
|
|
|
201
231
|
track(sessionID)
|
|
202
232
|
runtime.clearSessionStatus(sessionID)
|
|
203
233
|
const active = { jobId: "job", job: {}, startedAt: clock - 100 }
|
|
234
|
+
completion = "unknown"
|
|
235
|
+
assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
|
|
236
|
+
|
|
237
|
+
completion = "incomplete"
|
|
238
|
+
assert.equal(
|
|
239
|
+
await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active),
|
|
240
|
+
false,
|
|
241
|
+
"a transient idle boundary must not finalize a Loop-owned run while the assistant turn is still incomplete",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
completion = "completed"
|
|
204
245
|
assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
|
|
205
246
|
|
|
206
247
|
markToolCallActive({ sessionID, callID: "tool-finalize" })
|
|
@@ -210,6 +251,7 @@ try {
|
|
|
210
251
|
runtime.markSessionStatus(sessionID, "idle", clock + 1)
|
|
211
252
|
const client = { session: { status: async () => { throw new Error("status unavailable") } } }
|
|
212
253
|
assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true }), true)
|
|
254
|
+
completion = "unknown"
|
|
213
255
|
}
|
|
214
256
|
|
|
215
257
|
{
|
|
@@ -56,7 +56,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
|
|
|
56
56
|
await adapter.start()
|
|
57
57
|
const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" })
|
|
58
58
|
assert.deepEqual(result, { accepted: true })
|
|
59
|
-
assert.deepEqual(commands, [{
|
|
59
|
+
assert.deepEqual(commands, [{
|
|
60
|
+
sessionID: "ses_adapter",
|
|
61
|
+
name: "review",
|
|
62
|
+
command: "review",
|
|
63
|
+
arguments: "--quick",
|
|
64
|
+
text: "--quick",
|
|
65
|
+
}])
|
|
60
66
|
assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
|
|
61
67
|
await adapter.dispose()
|
|
62
68
|
}
|
|
@@ -103,7 +109,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
|
|
|
103
109
|
})
|
|
104
110
|
events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
|
|
105
111
|
await waitFor(() => commands.length === 1, "V2 command dispatch")
|
|
106
|
-
assert.deepEqual(commands[0], {
|
|
112
|
+
assert.deepEqual(commands[0], {
|
|
113
|
+
sessionID,
|
|
114
|
+
name: "review",
|
|
115
|
+
command: "review",
|
|
116
|
+
arguments: "--quick",
|
|
117
|
+
text: "--quick",
|
|
118
|
+
})
|
|
107
119
|
} finally {
|
|
108
120
|
await cleanup?.()
|
|
109
121
|
await rm(directory, { recursive: true, force: true })
|