@bybrawe/opencode-loop 0.6.6 → 0.6.8
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 +20 -0
- package/README.md +7 -3
- package/examples/OPENCODE2-0.6.7.md +26 -0
- package/examples/OPENCODE2-0.6.8.md +18 -0
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +3 -3
- package/scripts/desktop-command-diagnostics-test.mjs +92 -0
- package/scripts/desktop-command-diagnostics.mjs +237 -0
- package/scripts/host-goal-bootstrap-test.mjs +46 -0
- package/scripts/host-goal-bootstrap.mjs +71 -0
- package/scripts/host-goal-coexistence-canary.mjs +11 -2
- package/scripts/install-with-goals.mjs +11 -0
- package/scripts/native-plugin-lifetime-test.mjs +39 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +64 -2
- package/scripts/opencode2-loop-canary.mjs +55 -1
- package/scripts/package-native-smoke.mjs +40 -5
- package/scripts/publish-workflow-test.mjs +6 -2
- package/scripts/state-exfat-lock-test.mjs +99 -0
- package/scripts/state-integrity-test.mjs +47 -0
- package/scripts/state-process-test.mjs +172 -0
- package/src/index.js +249 -11
- package/src/native.js +268 -19
- package/src/server.js +268 -19
- package/src/source/core/state.js +236 -9
- package/src/source/opencode2/events.js +1 -1
- package/src/source/opencode2/native-plugin.js +4 -6
- package/src/source/opencode2/native-runtime.js +30 -4
package/src/source/core/state.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs"
|
|
2
|
+
import { randomUUID as stateLockRandomUUID } from "node:crypto"
|
|
2
3
|
import os from "node:os"
|
|
3
4
|
import path from "node:path"
|
|
4
5
|
import { safeID } from "./args.js"
|
|
@@ -12,6 +13,218 @@ function statePath(directory, sessionID) { return path.join(stateDir(directory),
|
|
|
12
13
|
export async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
|
|
13
14
|
export async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
|
|
14
15
|
|
|
16
|
+
const STATE_PROCESS_LOCK_TIMEOUT_MS = 5_000
|
|
17
|
+
const STATE_PROCESS_LOCK_POLL_MS = 20
|
|
18
|
+
|
|
19
|
+
function stateLockError(kind, detail) {
|
|
20
|
+
const error = new Error(`OpenCode Loop state lock ${kind}: ${detail}`)
|
|
21
|
+
error.code = "OPENCODE_LOOP_STATE_LOCK"
|
|
22
|
+
return error
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function assertStatePathSafe(directory, target) {
|
|
26
|
+
const root = path.resolve(directory)
|
|
27
|
+
const relative = path.relative(root, path.resolve(target))
|
|
28
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
29
|
+
throw stateLockError("unsafe_path", `path escapes project: ${target}`)
|
|
30
|
+
}
|
|
31
|
+
let current = root
|
|
32
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
33
|
+
current = path.join(current, part)
|
|
34
|
+
let info
|
|
35
|
+
try { info = await fs.lstat(current) }
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error?.code === "ENOENT") return
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
if (info.isSymbolicLink()) throw stateLockError("unsafe_path", `symbolic link or junction: ${current}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stateProcessLockOwner() {
|
|
45
|
+
const token = stateLockRandomUUID()
|
|
46
|
+
return {
|
|
47
|
+
schemaVersion: 1, pid: process.pid, token, acquiredAt: Date.now(),
|
|
48
|
+
candidateName: `.loop-lock-${process.pid}-${token}.json`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stateProcessLockOwnerValid(owner) {
|
|
53
|
+
return owner?.schemaVersion === 1
|
|
54
|
+
&& Number.isSafeInteger(owner.pid) && owner.pid > 0
|
|
55
|
+
&& typeof owner.token === "string" && /^[0-9a-f-]{36}$/i.test(owner.token)
|
|
56
|
+
&& Number.isFinite(owner.acquiredAt) && owner.acquiredAt > 0
|
|
57
|
+
&& owner.candidateName === `.loop-lock-${owner.pid}-${owner.token}.json`
|
|
58
|
+
&& path.basename(owner.candidateName) === owner.candidateName
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readStateProcessOwner(file) {
|
|
62
|
+
let raw
|
|
63
|
+
for (let attempt = 0; ; attempt++) {
|
|
64
|
+
try { raw = await fs.readFile(file, "utf8"); break }
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error?.code === "ENOENT") return null
|
|
67
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
|
|
68
|
+
await delay(10 * (attempt + 1))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// An exclusive-file fallback publishes an empty lock briefly before
|
|
72
|
+
// writing its owner. Allow a bounded read retry; stale empty locks still
|
|
73
|
+
// fail closed rather than being taken over.
|
|
74
|
+
if (raw.length === 0) {
|
|
75
|
+
for (let attempt = 0; attempt < 8 && raw.length === 0; attempt++) {
|
|
76
|
+
await delay(10 * (attempt + 1))
|
|
77
|
+
try { raw = await fs.readFile(file, "utf8") }
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error?.code === "ENOENT") return null
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let owner
|
|
85
|
+
try { owner = JSON.parse(raw) }
|
|
86
|
+
catch { throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`) }
|
|
87
|
+
if (!stateProcessLockOwnerValid(owner)) throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`)
|
|
88
|
+
return owner
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stateProcessAlive(pid) {
|
|
92
|
+
if (pid === process.pid) return true
|
|
93
|
+
try { process.kill(pid, 0); return true }
|
|
94
|
+
catch (error) { return error?.code !== "ESRCH" }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function removeStateProcessFile(file) {
|
|
98
|
+
for (let attempt = 0; ; attempt++) {
|
|
99
|
+
try { await fs.rm(file, { force: true }); return }
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
|
|
102
|
+
await delay(10 * (attempt + 1))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Keep atomic hard links on capable volumes. FAT/exFAT and some remote volumes
|
|
108
|
+
// reject them, so fall back to exclusive creation (O_EXCL) without overwriting
|
|
109
|
+
// any existing live lock or stale-owner cleanup election.
|
|
110
|
+
async function claimStateProcessLock(candidate, canonical) {
|
|
111
|
+
for (let attempt = 0; ; attempt++) {
|
|
112
|
+
try { await fs.link(candidate, canonical); return true }
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error?.code === "EEXIST") return false
|
|
115
|
+
if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
|
|
116
|
+
const contents = await fs.readFile(candidate)
|
|
117
|
+
let handle
|
|
118
|
+
try { handle = await fs.open(canonical, "wx", 0o600) }
|
|
119
|
+
catch (failure) {
|
|
120
|
+
if (failure?.code === "EEXIST") return false
|
|
121
|
+
throw failure
|
|
122
|
+
}
|
|
123
|
+
try { await handle.writeFile(contents) }
|
|
124
|
+
catch (failure) {
|
|
125
|
+
await handle.close().catch(() => {})
|
|
126
|
+
await removeStateProcessFile(canonical).catch(() => {})
|
|
127
|
+
throw failure
|
|
128
|
+
}
|
|
129
|
+
await handle.close()
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
|
|
133
|
+
await delay(10 * (attempt + 1))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function createStateLockCandidate(directory, owner) {
|
|
139
|
+
const candidate = path.join(stateDir(directory), owner.candidateName)
|
|
140
|
+
await assertStatePathSafe(directory, candidate)
|
|
141
|
+
await fs.writeFile(candidate, `${JSON.stringify(owner)}\n`, { flag: "wx", mode: 0o600 })
|
|
142
|
+
return candidate
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A cleanup-election hard link prevents two processes from deleting a newer
|
|
146
|
+
// lock while reclaiming a dead owner. Never reclaim a live or corrupt owner.
|
|
147
|
+
async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
|
|
148
|
+
const cleanupFile = `${canonical}.cleanup`
|
|
149
|
+
const cleanupOwner = stateProcessLockOwner()
|
|
150
|
+
const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner)
|
|
151
|
+
let elected = false
|
|
152
|
+
try {
|
|
153
|
+
await assertStatePathSafe(directory, cleanupFile)
|
|
154
|
+
elected = await claimStateProcessLock(cleanupCandidate, cleanupFile)
|
|
155
|
+
if (!elected) {
|
|
156
|
+
// The previous cleanup winner may itself have crashed. Reclaim that
|
|
157
|
+
// dead election through another hard-linked, owner-verified election.
|
|
158
|
+
// Never blindly unlink the election path: a live winner may replace it.
|
|
159
|
+
const existing = await readStateProcessOwner(cleanupFile)
|
|
160
|
+
if (existing && !stateProcessAlive(existing.pid)) {
|
|
161
|
+
if (depth >= 6) throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`)
|
|
162
|
+
await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1)
|
|
163
|
+
}
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
const current = await readStateProcessOwner(canonical)
|
|
167
|
+
if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid)) return false
|
|
168
|
+
await assertStatePathSafe(directory, canonical)
|
|
169
|
+
await removeStateProcessFile(canonical)
|
|
170
|
+
const staleCandidate = path.join(stateDir(directory), current.candidateName)
|
|
171
|
+
await assertStatePathSafe(directory, staleCandidate)
|
|
172
|
+
await removeStateProcessFile(staleCandidate).catch(() => {})
|
|
173
|
+
return true
|
|
174
|
+
} finally {
|
|
175
|
+
if (elected) {
|
|
176
|
+
const current = await readStateProcessOwner(cleanupFile).catch(() => null)
|
|
177
|
+
if (current?.token === cleanupOwner.token) await removeStateProcessFile(cleanupFile)
|
|
178
|
+
}
|
|
179
|
+
await removeStateProcessFile(cleanupCandidate).catch(() => {})
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function acquireStateProcessLock(directory, sessionID) {
|
|
184
|
+
const targetDirectory = stateDir(directory)
|
|
185
|
+
await assertStatePathSafe(directory, targetDirectory)
|
|
186
|
+
await ensureDir(targetDirectory)
|
|
187
|
+
await assertStatePathSafe(directory, targetDirectory)
|
|
188
|
+
const target = statePath(directory, sessionID)
|
|
189
|
+
const canonical = `${target}.lock`
|
|
190
|
+
await assertStatePathSafe(directory, target)
|
|
191
|
+
await assertStatePathSafe(directory, canonical)
|
|
192
|
+
const owner = stateProcessLockOwner()
|
|
193
|
+
const candidate = await createStateLockCandidate(directory, owner)
|
|
194
|
+
const started = Date.now()
|
|
195
|
+
let acquired = false
|
|
196
|
+
try {
|
|
197
|
+
while (true) {
|
|
198
|
+
await assertStatePathSafe(directory, canonical)
|
|
199
|
+
if (await claimStateProcessLock(candidate, canonical)) {
|
|
200
|
+
acquired = true
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
const current = await readStateProcessOwner(canonical)
|
|
204
|
+
if (current && !stateProcessAlive(current.pid)) await recoverDeadStateProcessLock(directory, canonical, current)
|
|
205
|
+
if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
|
|
206
|
+
throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`)
|
|
207
|
+
}
|
|
208
|
+
await delay(STATE_PROCESS_LOCK_POLL_MS)
|
|
209
|
+
}
|
|
210
|
+
return async () => {
|
|
211
|
+
const current = await readStateProcessOwner(canonical)
|
|
212
|
+
if (current?.token !== owner.token || current.pid !== owner.pid) {
|
|
213
|
+
throw stateLockError("lost", `session ${sessionID} lock ownership changed`)
|
|
214
|
+
}
|
|
215
|
+
await assertStatePathSafe(directory, canonical)
|
|
216
|
+
await removeStateProcessFile(canonical)
|
|
217
|
+
await removeStateProcessFile(candidate)
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
|
|
221
|
+
await removeStateProcessFile(canonical).catch(() => {})
|
|
222
|
+
}
|
|
223
|
+
await removeStateProcessFile(candidate).catch(() => {})
|
|
224
|
+
throw error
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
15
228
|
function stateLockKey(directory, sessionID) {
|
|
16
229
|
return `${path.resolve(directory)}:${safeID(sessionID)}`
|
|
17
230
|
}
|
|
@@ -24,21 +237,30 @@ async function withStateWriteLock(directory, sessionID, fn) {
|
|
|
24
237
|
const next = previous.catch(() => {}).then(() => current)
|
|
25
238
|
stateWriteLocks.set(key, next)
|
|
26
239
|
await previous.catch(() => {})
|
|
240
|
+
let unlock
|
|
27
241
|
try {
|
|
242
|
+
unlock = await acquireStateProcessLock(directory, sessionID)
|
|
28
243
|
return await fn()
|
|
29
244
|
} finally {
|
|
30
|
-
|
|
31
|
-
|
|
245
|
+
try { if (unlock) await unlock() }
|
|
246
|
+
finally {
|
|
247
|
+
release()
|
|
248
|
+
if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
|
|
249
|
+
}
|
|
32
250
|
}
|
|
33
251
|
}
|
|
34
252
|
|
|
35
253
|
async function readStateFile(directory, sessionID) {
|
|
36
254
|
const target = statePath(directory, sessionID)
|
|
255
|
+
await assertStatePathSafe(directory, target)
|
|
37
256
|
const attempts = 5
|
|
38
257
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
39
258
|
try {
|
|
40
259
|
const parsed = JSON.parse(await fs.readFile(target, "utf8"))
|
|
41
|
-
|
|
260
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
|
|
261
|
+
throw new SyntaxError("invalid Loop state schema: jobs must be an array")
|
|
262
|
+
}
|
|
263
|
+
return { version: 4, jobs: parsed.jobs }
|
|
42
264
|
} catch (error) {
|
|
43
265
|
if (error?.code === "ENOENT") return { version: 4, jobs: [] }
|
|
44
266
|
const transient = error instanceof SyntaxError || isRetriableStateWriteError(error)
|
|
@@ -50,7 +272,7 @@ async function readStateFile(directory, sessionID) {
|
|
|
50
272
|
await ensureDir(stateDir(directory))
|
|
51
273
|
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
|
|
52
274
|
} catch {}
|
|
53
|
-
|
|
275
|
+
throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`)
|
|
54
276
|
}
|
|
55
277
|
|
|
56
278
|
export async function readState(directory, sessionID) {
|
|
@@ -189,23 +411,28 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
|
|
|
189
411
|
|
|
190
412
|
export async function writeState(directory, sessionID, state) {
|
|
191
413
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
192
|
-
await ensureDir(stateDir(directory))
|
|
193
414
|
const target = statePath(directory, sessionID)
|
|
415
|
+
await assertStatePathSafe(directory, target)
|
|
416
|
+
const current = await readStateFile(directory, sessionID)
|
|
194
417
|
const baseline = state?.[STATE_BASELINE]
|
|
195
418
|
let jobs = structuredClone(state.jobs || [])
|
|
196
419
|
if (Array.isArray(baseline)) {
|
|
197
|
-
const current = await readStateFile(directory, sessionID)
|
|
198
420
|
jobs = mergeStateJobs(baseline, jobs, current.jobs || [])
|
|
199
|
-
state.jobs = structuredClone(jobs)
|
|
200
|
-
state[STATE_BASELINE] = structuredClone(jobs)
|
|
201
421
|
}
|
|
202
422
|
const payload = JSON.stringify({ version: 4, jobs }, null, 2)
|
|
203
423
|
await writeFileAtomically(target, payload)
|
|
424
|
+
// A failed disk write must not advance the in-memory snapshot baseline.
|
|
425
|
+
state.jobs = structuredClone(jobs)
|
|
426
|
+
if (Array.isArray(baseline)) state[STATE_BASELINE] = structuredClone(jobs)
|
|
204
427
|
})
|
|
205
428
|
}
|
|
206
429
|
|
|
207
430
|
export async function removeState(directory, sessionID) {
|
|
208
431
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
209
|
-
|
|
432
|
+
const target = statePath(directory, sessionID)
|
|
433
|
+
await assertStatePathSafe(directory, target)
|
|
434
|
+
try { await fs.unlink(target) }
|
|
435
|
+
catch (error) { if (error?.code !== "ENOENT") throw error }
|
|
210
436
|
})
|
|
211
437
|
}
|
|
438
|
+
|
|
@@ -67,7 +67,7 @@ export function normalizeOpenCode2NativeEvent(raw) {
|
|
|
67
67
|
}
|
|
68
68
|
if (["session.compaction.started", "session.compaction.ended", "session.compaction.failed"].includes(type)) {
|
|
69
69
|
if (!sessionID) return undefined
|
|
70
|
-
return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory })
|
|
70
|
+
return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory, reason: text(data.reason), inputID: text(data.inputID), error: text(record(data.error)?.message) })
|
|
71
71
|
}
|
|
72
72
|
if (["session.execution.failed", "session.execution.interrupted"].includes(type)) {
|
|
73
73
|
if (!sessionID) return undefined
|
|
@@ -21,7 +21,7 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
|
|
|
21
21
|
Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
|
|
22
22
|
Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
|
|
23
23
|
Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
|
|
24
|
-
|
|
24
|
+
Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
|
|
25
25
|
An unfinished dedicated Goal reserves its session; Loop will not override it.
|
|
26
26
|
--timeout pauses future iterations without aborting the current native model/tool/compaction operation.
|
|
27
27
|
--safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
|
|
@@ -61,11 +61,9 @@ export const OpenCodeLoopNativePlugin = Object.freeze({
|
|
|
61
61
|
prompt,
|
|
62
62
|
command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
|
|
63
63
|
wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
|
|
64
|
-
//
|
|
65
|
-
// and
|
|
66
|
-
|
|
67
|
-
// private host fields that may disappear between releases.
|
|
68
|
-
compact: undefined,
|
|
64
|
+
// Current V2 exposes public session.compact; older hosts may not.
|
|
65
|
+
// Shell execution and inbox cancellation remain separate from SessionDomain.
|
|
66
|
+
compact: typeof ctx.session.compact === "function" ? (request) => ctx.session.compact(request) : undefined,
|
|
69
67
|
shell: (request) => shellHost.dispatch(request),
|
|
70
68
|
cancel: undefined,
|
|
71
69
|
onError: (error) => { void appendLoopLog(directory, "v2-native-error", { message: String(error?.message || error) }).catch(() => {}) },
|
|
@@ -134,7 +134,7 @@ export function createNativeLoopRuntime(options = {}) {
|
|
|
134
134
|
async function finish(scope) {
|
|
135
135
|
const run = scope.active
|
|
136
136
|
if (!run || scope.compaction || scope.busy) return result({ reason: "no-terminal-boundary" })
|
|
137
|
-
if ((run.kind === "prompt" && !run.delivered) || (run.kind === "command" && !run.commandFinished) || (run.kind === "shell" && !run.shellFinished) || (["compact", "cadence"].includes(run.kind) && !run.compactFinished)) {
|
|
137
|
+
if ((run.kind === "prompt" && !run.delivered) || (run.kind === "command" && !run.commandFinished) || (run.kind === "shell" && !run.shellFinished) || (["compact", "cadence"].includes(run.kind) && (!run.compactionStarted || !run.compactFinished))) {
|
|
138
138
|
return result({ reason: "awaiting-owned-completion" })
|
|
139
139
|
}
|
|
140
140
|
clear(scope, "deadline")
|
|
@@ -368,6 +368,14 @@ export function createNativeLoopRuntime(options = {}) {
|
|
|
368
368
|
}
|
|
369
369
|
if (event.kind === "compaction" && event.action === "started") {
|
|
370
370
|
scope.compaction ||= { ended: false, terminal: false }
|
|
371
|
+
// Host versions may differ in compaction inbox-delivery events. The
|
|
372
|
+
// authoritative boundary is the published manual-compaction inputID
|
|
373
|
+
// matching our admitted durable item, not delivery alone.
|
|
374
|
+
if (
|
|
375
|
+
["compact", "cadence"].includes(scope.active?.kind)
|
|
376
|
+
&& event.reason === "manual"
|
|
377
|
+
&& event.inputID && event.inputID === scope.active.inboxID
|
|
378
|
+
) scope.active.compactionStarted = true
|
|
371
379
|
scope.busy = true
|
|
372
380
|
clear(scope, "timer")
|
|
373
381
|
return result({ reason: "compacting" })
|
|
@@ -420,10 +428,28 @@ export function createNativeLoopRuntime(options = {}) {
|
|
|
420
428
|
return finish(scope)
|
|
421
429
|
}
|
|
422
430
|
if ((event.kind === "session" && ["error", "failed", "interrupted"].includes(event.action)) || (event.kind === "compaction" && event.action === "failed")) {
|
|
431
|
+
const run = scope.active
|
|
432
|
+
// Unowned compaction failures must not pause unrelated scheduled Loop
|
|
433
|
+
// work. An automated host summary can fail when no Loop job owns it,
|
|
434
|
+
// and a different manual inputID cannot settle our pending request.
|
|
435
|
+
if (event.kind === "compaction" && event.action === "failed") {
|
|
436
|
+
const compactRun = ["compact", "cadence"].includes(run?.kind)
|
|
437
|
+
const owned = compactRun && event.reason === "manual" &&
|
|
438
|
+
((event.inputID && event.inputID === run.inboxID) ||
|
|
439
|
+
(!event.inputID && run.compactionStarted))
|
|
440
|
+
if (!run || (compactRun && !owned)) {
|
|
441
|
+
delete scope.compaction
|
|
442
|
+
scope.busy = false
|
|
443
|
+
return result({ reason: "unowned-compaction-failed" })
|
|
444
|
+
}
|
|
445
|
+
}
|
|
423
446
|
delete scope.compaction
|
|
424
447
|
scope.busy = false
|
|
425
|
-
const
|
|
426
|
-
const
|
|
448
|
+
const detail = String(event.error || "").replace(/\s+/g, " ").trim().slice(0, 160)
|
|
449
|
+
const reason = event.kind === "compaction"
|
|
450
|
+
? `compaction-failed${detail ? `: ${detail}` : ""}`
|
|
451
|
+
: event.reason || `${event.kind}-${event.action}`
|
|
452
|
+
const paused = await pauseActive(scope, reason)
|
|
427
453
|
if (run && (run.delivered || ["compact", "cadence", "shell"].includes(run.kind))) {
|
|
428
454
|
const state = await read(scope)
|
|
429
455
|
const job = (state.jobs || []).find((entry) => entry.id === run.jobID)
|
|
@@ -435,7 +461,7 @@ export function createNativeLoopRuntime(options = {}) {
|
|
|
435
461
|
if (event.kind === "compaction" && event.action === "ended") {
|
|
436
462
|
scope.compaction ||= { ended: false, terminal: false }
|
|
437
463
|
scope.compaction.ended = true
|
|
438
|
-
if (["compact", "cadence"].includes(scope.active?.kind)) scope.active.compactFinished = true
|
|
464
|
+
if (["compact", "cadence"].includes(scope.active?.kind) && scope.active.compactionStarted && event.reason === "manual") scope.active.compactFinished = true
|
|
439
465
|
if (!scope.compaction.terminal) return result({ reason: "awaiting-execution-terminal" })
|
|
440
466
|
} else if (event.kind === "session" && event.action === "idle") {
|
|
441
467
|
if (scope.compaction) { scope.compaction.terminal = true; if (!scope.compaction.ended) return result({ reason: "awaiting-compaction-end" }) }
|