@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +31 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -8
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +19 -10
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  9. package/scripts/goal-compaction-safety-test.mjs +68 -0
  10. package/scripts/goal-companion-test.mjs +1 -1
  11. package/scripts/host-adapter-contract-test.mjs +1 -0
  12. package/scripts/install-node.mjs +105 -58
  13. package/scripts/install-test.mjs +6 -1
  14. package/scripts/install-with-goals.mjs +17 -4
  15. package/scripts/job-workspace-test.mjs +11 -1
  16. package/scripts/loopd-test.mjs +74 -4
  17. package/scripts/loopd.mjs +153 -25
  18. package/scripts/native-cli-host-canary.mjs +118 -0
  19. package/scripts/native-cli-io-test.mjs +66 -0
  20. package/scripts/native-cli-test.mjs +130 -0
  21. package/scripts/native-coexistence-test.mjs +38 -0
  22. package/scripts/native-entry-test.mjs +77 -0
  23. package/scripts/native-guide-test.mjs +45 -0
  24. package/scripts/native-install-pair-test.mjs +73 -0
  25. package/scripts/native-install-test.mjs +107 -0
  26. package/scripts/native-lifecycle-test.mjs +100 -0
  27. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  28. package/scripts/native-presets-test.mjs +227 -0
  29. package/scripts/native-process-tree-fixture.mjs +72 -0
  30. package/scripts/native-process-tree-test.mjs +40 -0
  31. package/scripts/native-release.mjs +121 -0
  32. package/scripts/native-session-location-test.mjs +86 -0
  33. package/scripts/native-shell-test.mjs +38 -0
  34. package/scripts/native-v2-test.mjs +262 -0
  35. package/scripts/network-recovery-test.mjs +38 -1
  36. package/scripts/opencode-host.mjs +47 -0
  37. package/scripts/opencode2-loop-canary.mjs +119 -10
  38. package/scripts/package-native-smoke.mjs +31 -0
  39. package/scripts/publish-workflow-test.mjs +60 -14
  40. package/scripts/pulse-check.mjs +246 -0
  41. package/scripts/run-finalization-test.mjs +33 -0
  42. package/scripts/server-entry-test.mjs +15 -0
  43. package/scripts/session-error-recovery-test.mjs +186 -0
  44. package/scripts/session-status-test.mjs +42 -0
  45. package/scripts/v2-command-adapter-test.mjs +14 -2
  46. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  47. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  48. package/src/index.js +227 -53
  49. package/src/native.js +3135 -0
  50. package/src/server.js +4055 -0
  51. package/src/source/core/continuation.js +23 -0
  52. package/src/source/core/jobs.js +17 -0
  53. package/src/source/core/process.js +6 -4
  54. package/src/source/core/progress.js +58 -0
  55. package/src/source/legacy-v1.js +14 -3
  56. package/src/source/native.js +1 -0
  57. package/src/source/opencode/host.js +1 -0
  58. package/src/source/opencode/loop-commands.js +1 -27
  59. package/src/source/opencode/loop-registration.js +2 -12
  60. package/src/source/opencode/messages.js +1 -1
  61. package/src/source/opencode2/capabilities.js +1 -0
  62. package/src/source/opencode2/commands.js +52 -11
  63. package/src/source/opencode2/event-bridge.js +15 -3
  64. package/src/source/opencode2/events.js +31 -0
  65. package/src/source/opencode2/experimental.js +53 -7
  66. package/src/source/opencode2/native-companion.js +24 -0
  67. package/src/source/opencode2/native-plugin.js +153 -0
  68. package/src/source/opencode2/native-policy.js +106 -0
  69. package/src/source/opencode2/native-runtime.js +464 -0
  70. package/src/source/opencode2/native-shell.js +81 -0
  71. package/src/source/opencode2/prompt-runtime.js +38 -6
  72. package/src/source/opencode2/runtime-adapter.js +15 -2
  73. package/src/source/opencode2/session-location.js +16 -0
  74. package/src/source/runtime/executor.js +83 -0
  75. package/src/source/runtime/goal-steering.js +17 -3
  76. package/src/source/runtime/job-workspace.js +11 -2
  77. package/src/source/runtime/run-finalization.js +10 -0
  78. package/src/source/runtime/session-status.js +16 -2
  79. package/src/source/runtime/terminal-guard.js +32 -7
  80. package/src/source/server.js +26 -0
  81. 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 workflowPath = path.join(repoRoot, ".github", "workflows", "publish-npm.yml")
8
- const workflow = await readFile(workflowPath, "utf8")
9
-
10
- assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "publish workflow must checkout the release trigger SHA")
11
- assert.doesNotMatch(workflow, /ref:\s*main\b/, "publish workflow must not checkout a moving main branch")
12
- assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/, "release source must be verified against main history")
13
- assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/, "release tag must target the immutable trigger SHA")
14
- assert.match(workflow, /group:\s*npm-release/, "npm publication must be serialized")
15
- assert.match(workflow, /cancel-in-progress:\s*false/, "a newer release must not cancel an in-flight publication")
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
- const cleanupIndex = workflow.indexOf("Remove one-shot release branch")
21
- assert.ok(publishIndex >= 0 && verifyIndex > publishIndex, "npm publication must be verified after publish")
22
- assert.ok(tagIndex > verifyIndex, "tag/release creation must happen only after npm verification")
23
- assert.ok(cleanupIndex > tagIndex, "release branch cleanup must be the final release step")
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
- console.log("publish workflow tests passed")
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, [{ sessionID: "ses_adapter", command: "review", arguments: "--quick" }])
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], { sessionID, command: "review", arguments: "--quick" })
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 })