From a42447057d1ea09fad3e114af5ccce6ff371bdfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sat, 8 Aug 2026 20:05:11 +0300 Subject: [PATCH 1/9] chore: stage v0.5.24 compatibility patch --- scripts/apply-v0524.py | 622 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 622 insertions(+) create mode 100644 scripts/apply-v0524.py diff --git a/scripts/apply-v0524.py b/scripts/apply-v0524.py new file mode 100644 index 00000000..c9d37991 --- /dev/null +++ b/scripts/apply-v0524.py @@ -0,0 +1,622 @@ +from pathlib import Path + + +def replace_once(path, old, new): + p = Path(path) + text = p.read_text() + if old not in text: + raise SystemExit(f"missing expected block in {path}: {old[:120]!r}") + if text.count(old) != 1: + raise SystemExit(f"expected one match in {path}, found {text.count(old)}") + p.write_text(text.replace(old, new, 1)) + + +def insert_after(path, marker, addition): + replace_once(path, marker, marker + addition) + + +# Runtime tracking for native compaction lifecycle. +insert_after( + "src/index.js", + "const sessionStatusSeenAt = new Map()\n", + "const loopCompactionRequests = new Map()\n", +) + +# Current OpenCode summarize requires providerID/modelID. Also centralize recent +# message reads so stale-busy recovery can cross-check actual assistant completion. +old_compact = '''async function compactSession(client, sessionID) { + // OpenCode's TUI API accepts legacy keybind aliases (session_compact) in + // current builds, while some older docs/examples mention the event value + // (session.compact). Try the alias first, then the event value, then the + // session summarize endpoint as a last resort. + for (const command of ["session.compact", "session_compact"]) { + try { + await executeTuiCommand(client, command) + return true + } catch (error) { + await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) }) + } + } + try { + await sdkCall( + client.session.summarize.bind(client.session), + { path: { id: sessionID }, body: {} }, + { path: { sessionID }, body: {} }, + { sessionID }, + ) + return true + } catch (error) { + await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) }) + } + await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error") + return false +} +''' +new_compact = '''async function readRecentSessionMessages(client, sessionID, directory, limit = 20) { + if (!client?.session?.messages) return undefined + const query = { limit } + if (directory) query.directory = directory + try { + const messages = await sdkCall( + client.session.messages.bind(client.session), + { path: { id: sessionID }, query }, + { path: { sessionID }, query }, + { sessionID, ...query }, + ) + return Array.isArray(messages) ? messages : undefined + } catch { + return undefined + } +} + +function orderedSessionMessages(messages) { + return (messages || []) + .map((message, index) => { + const info = message?.info || message || {} + const created = Number(info?.time?.created || 0) + return { message, index, created: Number.isFinite(created) ? created : 0 } + }) + .sort((a, b) => a.created - b.created || a.index - b.index) + .map((entry) => entry.message) +} + +async function activeRunCompletionFromMessages(directory, client, sessionID, active) { + const messages = await readRecentSessionMessages(client, sessionID, directory) + if (!messages) return "unknown" + const tail = orderedSessionMessages(messages).at(-1) + const info = tail?.info || tail + if (!info || info.role !== "assistant") return "incomplete" + const completed = Number(info?.time?.completed || 0) + const created = Number(info?.time?.created || 0) + if (!Number.isFinite(completed) || completed <= 0) return "incomplete" + const startedAt = Number(active?.startedAt || 0) + if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete" + return "completed" +} + +async function resolveCompactionModel(directory, client, sessionID, preferredModel) { + const preferred = normalizedModelRef(preferredModel) + if (preferred) return preferred + const cached = normalizedModelRef(sessionExecutionContexts.get(sessionID)?.model) + if (cached) return cached + const captured = await captureSessionExecutionContext(client, sessionID) + const capturedModel = normalizedModelRef(captured?.model) + if (capturedModel) return capturedModel + const messages = await readRecentSessionMessages(client, sessionID, directory) + for (const message of orderedSessionMessages(messages).reverse()) { + const info = message?.info || message + const model = normalizedModelRef(info?.model) || normalizedModelRef(info) + if (!model) continue + const previous = sessionExecutionContexts.get(sessionID) || {} + sessionExecutionContexts.set(sessionID, { ...previous, model }) + return model + } + return undefined +} + +async function compactSession(directory, client, sessionID, preferredModel) { + // Prefer the native TUI command when a TUI is present. Headless/server hosts + // fall back to session.summarize, whose current API requires an explicit + // provider/model pair. + for (const command of ["session.compact", "session_compact"]) { + try { + await executeTuiCommand(client, command) + return true + } catch (error) { + await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) }) + } + } + try { + if (!client?.session?.summarize) throw new Error("client.session.summarize is not available") + const model = await resolveCompactionModel(directory, client, sessionID, preferredModel) + if (!model) throw new Error("could not resolve a provider/model for session.summarize") + const body = { providerID: model.providerID, modelID: model.modelID, auto: false } + await sdkCall( + client.session.summarize.bind(client.session), + { path: { id: sessionID }, body }, + { path: { sessionID }, body }, + { sessionID, ...body }, + ) + return true + } catch (error) { + await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) }) + } + await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error") + return false +} +''' +replace_once("src/index.js", old_compact, new_compact) + +# Automatic compact-every must wait for compaction to finish before starting the +# actual scheduled action. Returning a structured result lets the scheduler hold +# the job until OpenCode emits session.compacted/idle. +old_maybe_compact = '''async function maybeCompact(client, sessionID, job) { + const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount + const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs) + if (!dueRuns && !dueTime) return job + if (await compactSession(client, sessionID)) { + job.lastCompactAt = now() + job.lastCompactRunCount = job.runCount || 0 + } + return job +} +''' +new_maybe_compact = '''async function maybeCompact(directory, client, sessionID, job) { + const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount + const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs) + if (!dueRuns && !dueTime) return { job, started: false } + beginLoopCompaction(sessionID, job.id, true) + if (await compactSession(directory, client, sessionID, job.model)) { + job.lastCompactAt = now() + job.lastCompactRunCount = job.runCount || 0 + return { job, started: true } + } + loopCompactionRequests.delete(sessionID) + return { job, started: false } +} +''' +replace_once("src/index.js", old_maybe_compact, new_maybe_compact) + +# Clear lifecycle state with the rest of the session runtime. +replace_once( + "src/index.js", + " sessionStatusSeenAt.delete(sessionID)\n sessionExecutionContexts.delete(sessionID)\n", + " sessionStatusSeenAt.delete(sessionID)\n sessionExecutionContexts.delete(sessionID)\n loopCompactionRequests.delete(sessionID)\n", +) + +# Confirm stale busy/retry with the completed assistant tail. Preserve the old +# timeout-only recovery only when message history is unavailable, not when the +# history explicitly shows an unfinished turn. +old_can_finalize = '''async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) { + if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false + if (!options.requireIdle && !options.forceStale) return true + if (options.forceStale && staleActiveRun(sessionID)) return true + if (!options.requireIdle) return false + + const live = await readLiveSessionStatus(client, sessionID, directory) + if (live?.type) return live.type === "idle" + + const cached = sessionStatuses.get(sessionID) + const seenAt = sessionStatusSeenAt.get(sessionID) || 0 + return cached === "idle" && seenAt > (active.startedAt || 0) +} +''' +new_can_finalize = '''async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) { + if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false + if (!options.requireIdle && !options.forceStale) return true + + const completion = options.forceStale + ? await activeRunCompletionFromMessages(directory, client, sessionID, active) + : undefined + if (completion === "completed") return true + if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID) + + const live = await readLiveSessionStatus(client, sessionID, directory) + if (live?.type === "idle") return true + if (live?.type) { + if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true + return false + } + + if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true + const cached = sessionStatuses.get(sessionID) + const seenAt = sessionStatusSeenAt.get(sessionID) || 0 + return cached === "idle" && seenAt > (active.startedAt || 0) +} +''' +replace_once("src/index.js", old_can_finalize, new_can_finalize) + +old_status_recovery = ''' if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) { + sessionStatuses.set(sessionID, "idle") + sessionStatusSeenAt.set(sessionID, now()) + return "idle" + } +''' +new_status_recovery = ''' if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) { + const active = activeRuns.get(sessionID) + if (active) { + const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active) + if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) { + sessionStatuses.set(sessionID, "idle") + sessionStatusSeenAt.set(sessionID, now()) + await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", { + sessionID, + job: active.job?.name || active.jobId, + startedAt: active.startedAt, + }) + return "idle" + } + } + } +''' +replace_once("src/index.js", old_status_recovery, new_status_recovery) + +# Native compaction tracking and token-safe finalization. +old_clear_active = '''function clearActiveRun(sessionID) { + const active = activeRuns.get(sessionID) + if (active?.timer) clearTimeout(active.timer) + activeRuns.delete(sessionID) +} +''' +new_clear_active = '''function clearActiveRun(sessionID) { + const active = activeRuns.get(sessionID) + if (active?.timer) clearTimeout(active.timer) + const compact = loopCompactionRequests.get(sessionID) + if (!compact || !active || compact.jobId === active.jobId) loopCompactionRequests.delete(sessionID) + activeRuns.delete(sessionID) +} + +function beginLoopCompaction(sessionID, jobId, resumeAfter = false) { + loopCompactionRequests.set(sessionID, { + jobId, + resumeAfter, + requestedAt: now(), + startedAt: 0, + completedAt: 0, + }) +} + +async function noteLoopCompactionStarted(directory, sessionID) { + const pending = loopCompactionRequests.get(sessionID) + if (!pending) return false + if (!pending.startedAt) { + pending.startedAt = now() + loopCompactionRequests.set(sessionID, pending) + await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter }) + } + return true +} + +async function finalizeLoopCompaction(directory, client, sessionID) { + const pending = loopCompactionRequests.get(sessionID) + const active = activeRuns.get(sessionID) + if (!pending || !active || pending.jobId !== active.jobId) return false + if (active.compactionOnly) { + clearActiveRun(sessionID) + sessionStatuses.delete(sessionID) + sessionStatusSeenAt.delete(sessionID) + await appendLoopLog(directory, "compact-finished", { sessionID, job: pending.jobId, resumeAfter: true }) + await scheduleDueWork(directory, client, sessionID) + return true + } + return await finalizeActiveRun(directory, client, sessionID) +} + +async function noteLoopCompactionCompleted(directory, client, sessionID) { + const pending = loopCompactionRequests.get(sessionID) + if (!pending) return false + pending.completedAt = now() + loopCompactionRequests.set(sessionID, pending) + await appendLoopLog(directory, "compact-event", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter }) + const timer = setTimeout(() => { + finalizeLoopCompaction(directory, client, sessionID) + .catch((error) => log(client, "error", "compaction finalization failed", { error: sdkErrorMessage(error) })) + }, 0) + timer.unref?.() + return true +} +''' +replace_once("src/index.js", old_clear_active, new_clear_active) + +# Route explicit compact jobs/commands through the lifecycle tracker and ensure a +# failed compact command does not masquerade as a started assistant turn. +old_fire_compact = ''' if (kind === "compact") { + const ok = await compactSession(client, sessionID) + return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" } + } +''' +new_fire_compact = ''' if (kind === "compact") { + beginLoopCompaction(sessionID, job.id, false) + const ok = await compactSession(directory, client, sessionID, model) + if (!ok) loopCompactionRequests.delete(sessionID) + return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok } + } +''' +replace_once("src/index.js", old_fire_compact, new_fire_compact) + +old_command_compact = ''' const tuiCommand = compactTuiCommandName(command) + if (tuiCommand) { + guardLoopOwnedUserMessage(sessionID) + await compactSession(client, sessionID) + return { startsAssistantTurn: true } + } +''' +new_command_compact = ''' const tuiCommand = compactTuiCommandName(command) + if (tuiCommand) { + guardLoopOwnedUserMessage(sessionID) + beginLoopCompaction(sessionID, job.id, false) + const ok = await compactSession(directory, client, sessionID, model) + if (!ok) loopCompactionRequests.delete(sessionID) + return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok } + } +''' +replace_once("src/index.js", old_command_compact, new_command_compact) + +# Split automatic pre-action compaction into its own active phase so the actual +# prompt/shell action cannot overlap the compaction turn. +old_prepare = ''' job = await ensureBranch(directory, job, client, sessionID) + job = await maybeCompact(client, sessionID, job) + job.watchTriggered = false + job.lastRunAt = now() + job.runCount = (job.runCount || 0) + 1 +''' +new_prepare = ''' job = await ensureBranch(directory, job, client, sessionID) + const compactResult = await maybeCompact(directory, client, sessionID, job) + job = compactResult.job + if (compactResult.started) { + state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate) + await writeState(directory, sessionID, state) + let timer + if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs) + const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}` + activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true }) + const pending = loopCompactionRequests.get(sessionID) + if (pending?.jobId === job.id && pending.completedAt) { + await finalizeLoopCompaction(directory, client, sessionID) + return + } + sessionStatuses.set(sessionID, "busy") + sessionStatusSeenAt.set(sessionID, now()) + await reschedule(BUSY_RETRY_MS) + return + } + job.watchTriggered = false + job.lastRunAt = now() + job.runCount = (job.runCount || 0) + 1 +''' +replace_once("src/index.js", old_prepare, new_prepare) + +old_active_set = ''' activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken }) + if (result.dispatch) { +''' +new_active_set = ''' activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionAction: result.compaction === true }) + if (result.compaction) { + const pending = loopCompactionRequests.get(sessionID) + if (pending?.jobId === job.id && pending.completedAt) { + await finalizeLoopCompaction(directory, client, sessionID) + return + } + } + if (result.dispatch) { +''' +replace_once("src/index.js", old_active_set, new_active_set) + +# Wire the latest native OpenCode compaction hook/event. Older hosts simply never +# invoke the extra hook/event, leaving the existing idle/status fallback intact. +old_hooks = ''' tool: goalTools(directory), + "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) }, + "tool.execute.before": async (input) => { markToolCallActive(input) }, + "tool.execute.after": async (input) => { markToolCallFinished(input) }, + event: async ({ event }) => { +''' +new_hooks = ''' tool: goalTools(directory), + "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) }, + "tool.execute.before": async (input) => { markToolCallActive(input) }, + "tool.execute.after": async (input) => { markToolCallFinished(input) }, + "experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) }, + event: async ({ event }) => { + if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID) +''' +replace_once("src/index.js", old_hooks, new_hooks) + +# Test harness: message history + summarize request capture + TUI-only failure. +replace_once( + "scripts/comprehensive-test.mjs", + ''' shells: [], + toasts: [], + tuiCommands: [], + } + const statuses = new Map([[sessionID, "idle"]]) +''', + ''' shells: [], + summaries: [], + messageReads: [], + toasts: [], + tuiCommands: [], + } + const statuses = new Map([[sessionID, "idle"]]) + const messageHistory = Array.isArray(options.messages) ? structuredClone(options.messages) : [] +''', +) +replace_once( + "scripts/comprehensive-test.mjs", + ''' records.tuiCommands.push(args.body.command) + if (options.failCompact) throw new Error("simulated TUI compact failure") + return { data: true } +''', + ''' records.tuiCommands.push(args.body.command) + if (options.failCompact || options.failTuiCompact) throw new Error("simulated TUI compact failure") + return { data: true } +''', +) +replace_once( + "scripts/comprehensive-test.mjs", + ''' status: async (args) => { + assert.equal(args?.query?.directory, directory) + // Current OpenCode omits idle sessions from this response. + return { + data: Object.fromEntries( + [...statuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }]), + ), + } + }, + summarize: async () => { + if (options.failCompact) throw new Error("simulated summarize failure") + return { data: true } + }, +''', + ''' status: async (args) => { + assert.equal(args?.query?.directory, directory) + // Current OpenCode omits idle sessions from this response. + return { + data: Object.fromEntries( + [...statuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }]), + ), + } + }, + messages: async (args) => { + assert.equal(args?.path?.id, sessionID) + assert.equal(args?.query?.directory, directory) + records.messageReads.push(args) + return { data: structuredClone(messageHistory) } + }, + summarize: async (args) => { + assert.equal(args?.path?.id, sessionID) + records.summaries.push(args?.body) + if (options.failCompact) throw new Error("simulated summarize failure") + return { data: true } + }, +''', +) +replace_once( + "scripts/comprehensive-test.mjs", + ''' records, + sessionID, + stateFile, + statuses, +''', + ''' records, + sessionID, + stateFile, + statuses, + messageHistory, +''', +) + +# Add focused regressions after action routing. +marker = '''async function testPromptDispatchFailureRecovery() { +''' +new_tests = '''async function testNativeCompactionLifecycleAndFallback() { + let h = await createHarness({ failTuiCompact: true }) + try { + await h.command("loop-compact", "0s --no-now") + await h.command("loop-now", "compact") + assert.deepEqual(h.records.summaries[0], { + providerID: "test-provider", + modelID: "test-model", + auto: false, + }, "headless compact fallback must satisfy the current OpenCode summarize payload") + assert.equal(typeof h.hooks["experimental.session.compacting"], "function") + const compactOutput = { context: [], prompt: undefined } + await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput) + assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt") + await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } }) + await delay(20) + const state = await h.readState() + assert.ok(state.jobs[0].lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery") + } finally { + await h.cleanup() + } + + h = await createHarness() + try { + await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 continue after compaction") + const seeded = await h.readState() + seeded.jobs[0].runCount = 1 + seeded.jobs[0].lastRunAt = 0 + await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") + + await h.command("loop-now", "compact-chain") + assert.equal(h.records.tuiCommands.length, 1, "compact-every must start compaction") + assert.equal(h.actionTexts().length, 0, "the scheduled action must not overlap a pending compaction") + assert.equal((await h.readState()).jobs[0].runCount, 1, "compaction-only phase must not count as a normal loop run") + + await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, { context: [], prompt: undefined }) + await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } }) + await delay(20) + assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action") + } finally { + await h.cleanup() + } +} + +async function testStaleBusyUsesCompletedAssistantTail() { + let h = await createHarness() + try { + await h.command("loop", "5m --no-now --name stale-complete continue safely") + await h.command("loop-now", "stale-complete") + h.statuses.set(h.sessionID, "busy") + const completedAt = Date.now() + 5 + h.messageHistory.splice(0, h.messageHistory.length, + { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] }, + { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] }, + ) + await h.command("loop-now", "stale-complete") + const state = await h.readState() + assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status") + assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history") + } finally { + await h.cleanup() + } + + h = await createHarness() + try { + await h.command("loop", "5m --no-now --name stale-incomplete continue safely") + const seeded = await h.readState() + seeded.jobs[0].staleActiveRecoveryMs = 1 + await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") + await h.command("loop-now", "stale-incomplete") + await delay(10) + h.statuses.set(h.sessionID, "busy") + const createdAt = Date.now() + h.messageHistory.splice(0, h.messageHistory.length, + { info: { id: "usr_running", sessionID: h.sessionID, role: "user", time: { created: createdAt - 1 } }, parts: [] }, + { info: { id: "asst_running", sessionID: h.sessionID, role: "assistant", time: { created: createdAt } }, parts: [] }, + ) + await h.command("loop-now", "stale-incomplete") + const state = await h.readState() + assert.equal(state.jobs[0].lastFinishedAt, undefined, "an unfinished assistant tail must never be force-finalized just because the active-run timeout elapsed") + assert.equal(h.actionTexts().length, 1, "unfinished work must not be overlapped by a replacement prompt") + } finally { + await h.cleanup() + } +} + +''' +replace_once("scripts/comprehensive-test.mjs", marker, new_tests + marker) +replace_once( + "scripts/comprehensive-test.mjs", + '''await testActionRoutingAndSafety() +await testPromptDispatchFailureRecovery() +''', + '''await testActionRoutingAndSafety() +await testNativeCompactionLifecycleAndFallback() +await testStaleBusyUsesCompletedAssistantTail() +await testPromptDispatchFailureRecovery() +''', +) + +# Release metadata/docs. +replace_once("package.json", '"version": "0.5.23"', '"version": "0.5.24"') +replace_once( + "CHANGELOG.md", + "# Changelog\n\n", + "# Changelog\n\n## 0.5.24\n\n- Cross-check stale OpenCode `busy`/`retry` status against the chronological session tail and only recover early when the latest assistant message has a real `time.completed`; an explicitly unfinished assistant tail is never force-finalized.\n- Track scheduled compaction through OpenCode's native `experimental.session.compacting` hook and `session.compacted` event, while retaining idle/status fallbacks for older hosts.\n- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction.\n- Fix headless/server compact fallback for current OpenCode by supplying the required `providerID`, `modelID`, and `auto: false` payload to `session.summarize`.\n- Add deterministic regressions for completed-vs-running assistant tails, native compaction completion, compact/action serialization, and current summarize payloads.\n\n", +) +replace_once( + "README.md", + "**v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.**", + "**v0.5.24 uses OpenCode's native compaction lifecycle, serializes compact-before-run work, fixes current headless summarize payloads, and validates stale `busy` recovery against a genuinely completed assistant tail.** **v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.**", +) + +print("v0.5.24 patch applied") From a1d1f511550d00adda603710801949fbd6e7e4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sat, 8 Aug 2026 20:05:21 +0300 Subject: [PATCH 2/9] ci: verify v0.5.24 candidate on PR --- .github/workflows/apply-v0524-pr.yml | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/apply-v0524-pr.yml diff --git a/.github/workflows/apply-v0524-pr.yml b/.github/workflows/apply-v0524-pr.yml new file mode 100644 index 00000000..5890c702 --- /dev/null +++ b/.github/workflows/apply-v0524-pr.yml @@ -0,0 +1,44 @@ +name: Apply v0.5.24 on PR + +on: + pull_request: + branches: + - main + +permissions: + contents: write + +jobs: + apply: + if: github.head_ref == 'fix/v0.5.24-native-compaction-status' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + - uses: actions/setup-node@v6 + with: + node-version: "24" + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Apply v0.5.24 patch + run: python scripts/apply-v0524.py + - name: Refresh lockfile + run: npm install --package-lock-only --ignore-scripts + - name: Verify candidate + run: | + npm ci + npm run check + npm test + bun -e "await import('./src/index.js')" + npm pack --dry-run + - name: Commit verified candidate + shell: bash + run: | + rm scripts/apply-v0524.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/index.js scripts/comprehensive-test.mjs package.json package-lock.json CHANGELOG.md README.md scripts/apply-v0524.py + git commit -m "fix: harden native compaction and stale status recovery" + git push origin HEAD:${{ github.head_ref }} From 3efc4c60c3318377b70a1dd8471e01aef1870cca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:05:59 +0000 Subject: [PATCH 3/9] fix: harden native compaction and stale status recovery --- CHANGELOG.md | 8 + README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/apply-v0524.py | 622 --------------------------------- scripts/comprehensive-test.mjs | 104 +++++- src/index.js | 225 ++++++++++-- 7 files changed, 313 insertions(+), 654 deletions(-) delete mode 100644 scripts/apply-v0524.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c089b68..1c9279ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.5.24 + +- Cross-check stale OpenCode `busy`/`retry` status against the chronological session tail and only recover early when the latest assistant message has a real `time.completed`; an explicitly unfinished assistant tail is never force-finalized. +- Track scheduled compaction through OpenCode's native `experimental.session.compacting` hook and `session.compacted` event, while retaining idle/status fallbacks for older hosts. +- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction. +- Fix headless/server compact fallback for current OpenCode by supplying the required `providerID`, `modelID`, and `auto: false` payload to `session.summarize`. +- Add deterministic regressions for completed-vs-running assistant tails, native compaction completion, compact/action serialization, and current summarize payloads. + ## 0.5.23 - Track non-blocking `session.prompt` and `session.shell` dispatch promises instead of treating every fire-and-forget request as successfully started. diff --git a/README.md b/README.md index a7873b05..1b6b05db 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode ## Current status -**v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.** **v0.5.22 adds forward-compatible OpenCode command handling plus Bun and peer-range compatibility CI.** **v0.5.21 targets current OpenCode 1.18.x compatibility and safer releases.** Server-plugin control commands keep their locked-down acknowledgement prompt instead of creating an empty command message, `/compact` prefers the current `session.compact` TUI command, and CI now covers Ubuntu and Windows before publishing. **v0.5.20 fixes Windows TUI state writes.** Session job state is written through the OS temp directory with rename plus copy/unlink fallback and short retries, so antivirus locks and OpenCode snapshots no longer drop `/loop` jobs with `EPERM` on rename. **v0.5.19** hardens Goal Mode, package updates, and the background daemon: package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load. +**v0.5.24 uses OpenCode's native compaction lifecycle, serializes compact-before-run work, fixes current headless summarize payloads, and validates stale `busy` recovery against a genuinely completed assistant tail.** **v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.** **v0.5.22 adds forward-compatible OpenCode command handling plus Bun and peer-range compatibility CI.** **v0.5.21 targets current OpenCode 1.18.x compatibility and safer releases.** Server-plugin control commands keep their locked-down acknowledgement prompt instead of creating an empty command message, `/compact` prefers the current `session.compact` TUI command, and CI now covers Ubuntu and Windows before publishing. **v0.5.20 fixes Windows TUI state writes.** Session job state is written through the OS temp directory with rename plus copy/unlink fallback and short retries, so antivirus locks and OpenCode snapshots no longer drop `/loop` jobs with `EPERM` on rename. **v0.5.19** hardens Goal Mode, package updates, and the background daemon: package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load. The known update-related symptoms from older builds are fixed: diff --git a/package-lock.json b/package-lock.json index f7c2eede..b0fec9b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bybrawe/opencode-loop", - "version": "0.5.23", + "version": "0.5.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bybrawe/opencode-loop", - "version": "0.5.23", + "version": "0.5.24", "license": "MIT", "bin": { "opencode-loop": "scripts/install-node.mjs", diff --git a/package.json b/package.json index 71e2af74..1b8a14f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bybrawe/opencode-loop", - "version": "0.5.23", + "version": "0.5.24", "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.", "type": "module", "main": "src/index.js", diff --git a/scripts/apply-v0524.py b/scripts/apply-v0524.py deleted file mode 100644 index c9d37991..00000000 --- a/scripts/apply-v0524.py +++ /dev/null @@ -1,622 +0,0 @@ -from pathlib import Path - - -def replace_once(path, old, new): - p = Path(path) - text = p.read_text() - if old not in text: - raise SystemExit(f"missing expected block in {path}: {old[:120]!r}") - if text.count(old) != 1: - raise SystemExit(f"expected one match in {path}, found {text.count(old)}") - p.write_text(text.replace(old, new, 1)) - - -def insert_after(path, marker, addition): - replace_once(path, marker, marker + addition) - - -# Runtime tracking for native compaction lifecycle. -insert_after( - "src/index.js", - "const sessionStatusSeenAt = new Map()\n", - "const loopCompactionRequests = new Map()\n", -) - -# Current OpenCode summarize requires providerID/modelID. Also centralize recent -# message reads so stale-busy recovery can cross-check actual assistant completion. -old_compact = '''async function compactSession(client, sessionID) { - // OpenCode's TUI API accepts legacy keybind aliases (session_compact) in - // current builds, while some older docs/examples mention the event value - // (session.compact). Try the alias first, then the event value, then the - // session summarize endpoint as a last resort. - for (const command of ["session.compact", "session_compact"]) { - try { - await executeTuiCommand(client, command) - return true - } catch (error) { - await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) }) - } - } - try { - await sdkCall( - client.session.summarize.bind(client.session), - { path: { id: sessionID }, body: {} }, - { path: { sessionID }, body: {} }, - { sessionID }, - ) - return true - } catch (error) { - await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) }) - } - await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error") - return false -} -''' -new_compact = '''async function readRecentSessionMessages(client, sessionID, directory, limit = 20) { - if (!client?.session?.messages) return undefined - const query = { limit } - if (directory) query.directory = directory - try { - const messages = await sdkCall( - client.session.messages.bind(client.session), - { path: { id: sessionID }, query }, - { path: { sessionID }, query }, - { sessionID, ...query }, - ) - return Array.isArray(messages) ? messages : undefined - } catch { - return undefined - } -} - -function orderedSessionMessages(messages) { - return (messages || []) - .map((message, index) => { - const info = message?.info || message || {} - const created = Number(info?.time?.created || 0) - return { message, index, created: Number.isFinite(created) ? created : 0 } - }) - .sort((a, b) => a.created - b.created || a.index - b.index) - .map((entry) => entry.message) -} - -async function activeRunCompletionFromMessages(directory, client, sessionID, active) { - const messages = await readRecentSessionMessages(client, sessionID, directory) - if (!messages) return "unknown" - const tail = orderedSessionMessages(messages).at(-1) - const info = tail?.info || tail - if (!info || info.role !== "assistant") return "incomplete" - const completed = Number(info?.time?.completed || 0) - const created = Number(info?.time?.created || 0) - if (!Number.isFinite(completed) || completed <= 0) return "incomplete" - const startedAt = Number(active?.startedAt || 0) - if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete" - return "completed" -} - -async function resolveCompactionModel(directory, client, sessionID, preferredModel) { - const preferred = normalizedModelRef(preferredModel) - if (preferred) return preferred - const cached = normalizedModelRef(sessionExecutionContexts.get(sessionID)?.model) - if (cached) return cached - const captured = await captureSessionExecutionContext(client, sessionID) - const capturedModel = normalizedModelRef(captured?.model) - if (capturedModel) return capturedModel - const messages = await readRecentSessionMessages(client, sessionID, directory) - for (const message of orderedSessionMessages(messages).reverse()) { - const info = message?.info || message - const model = normalizedModelRef(info?.model) || normalizedModelRef(info) - if (!model) continue - const previous = sessionExecutionContexts.get(sessionID) || {} - sessionExecutionContexts.set(sessionID, { ...previous, model }) - return model - } - return undefined -} - -async function compactSession(directory, client, sessionID, preferredModel) { - // Prefer the native TUI command when a TUI is present. Headless/server hosts - // fall back to session.summarize, whose current API requires an explicit - // provider/model pair. - for (const command of ["session.compact", "session_compact"]) { - try { - await executeTuiCommand(client, command) - return true - } catch (error) { - await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) }) - } - } - try { - if (!client?.session?.summarize) throw new Error("client.session.summarize is not available") - const model = await resolveCompactionModel(directory, client, sessionID, preferredModel) - if (!model) throw new Error("could not resolve a provider/model for session.summarize") - const body = { providerID: model.providerID, modelID: model.modelID, auto: false } - await sdkCall( - client.session.summarize.bind(client.session), - { path: { id: sessionID }, body }, - { path: { sessionID }, body }, - { sessionID, ...body }, - ) - return true - } catch (error) { - await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) }) - } - await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error") - return false -} -''' -replace_once("src/index.js", old_compact, new_compact) - -# Automatic compact-every must wait for compaction to finish before starting the -# actual scheduled action. Returning a structured result lets the scheduler hold -# the job until OpenCode emits session.compacted/idle. -old_maybe_compact = '''async function maybeCompact(client, sessionID, job) { - const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount - const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs) - if (!dueRuns && !dueTime) return job - if (await compactSession(client, sessionID)) { - job.lastCompactAt = now() - job.lastCompactRunCount = job.runCount || 0 - } - return job -} -''' -new_maybe_compact = '''async function maybeCompact(directory, client, sessionID, job) { - const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount - const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs) - if (!dueRuns && !dueTime) return { job, started: false } - beginLoopCompaction(sessionID, job.id, true) - if (await compactSession(directory, client, sessionID, job.model)) { - job.lastCompactAt = now() - job.lastCompactRunCount = job.runCount || 0 - return { job, started: true } - } - loopCompactionRequests.delete(sessionID) - return { job, started: false } -} -''' -replace_once("src/index.js", old_maybe_compact, new_maybe_compact) - -# Clear lifecycle state with the rest of the session runtime. -replace_once( - "src/index.js", - " sessionStatusSeenAt.delete(sessionID)\n sessionExecutionContexts.delete(sessionID)\n", - " sessionStatusSeenAt.delete(sessionID)\n sessionExecutionContexts.delete(sessionID)\n loopCompactionRequests.delete(sessionID)\n", -) - -# Confirm stale busy/retry with the completed assistant tail. Preserve the old -# timeout-only recovery only when message history is unavailable, not when the -# history explicitly shows an unfinished turn. -old_can_finalize = '''async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) { - if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false - if (!options.requireIdle && !options.forceStale) return true - if (options.forceStale && staleActiveRun(sessionID)) return true - if (!options.requireIdle) return false - - const live = await readLiveSessionStatus(client, sessionID, directory) - if (live?.type) return live.type === "idle" - - const cached = sessionStatuses.get(sessionID) - const seenAt = sessionStatusSeenAt.get(sessionID) || 0 - return cached === "idle" && seenAt > (active.startedAt || 0) -} -''' -new_can_finalize = '''async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) { - if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false - if (!options.requireIdle && !options.forceStale) return true - - const completion = options.forceStale - ? await activeRunCompletionFromMessages(directory, client, sessionID, active) - : undefined - if (completion === "completed") return true - if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID) - - const live = await readLiveSessionStatus(client, sessionID, directory) - if (live?.type === "idle") return true - if (live?.type) { - if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true - return false - } - - if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true - const cached = sessionStatuses.get(sessionID) - const seenAt = sessionStatusSeenAt.get(sessionID) || 0 - return cached === "idle" && seenAt > (active.startedAt || 0) -} -''' -replace_once("src/index.js", old_can_finalize, new_can_finalize) - -old_status_recovery = ''' if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) { - sessionStatuses.set(sessionID, "idle") - sessionStatusSeenAt.set(sessionID, now()) - return "idle" - } -''' -new_status_recovery = ''' if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) { - const active = activeRuns.get(sessionID) - if (active) { - const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active) - if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) { - sessionStatuses.set(sessionID, "idle") - sessionStatusSeenAt.set(sessionID, now()) - await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", { - sessionID, - job: active.job?.name || active.jobId, - startedAt: active.startedAt, - }) - return "idle" - } - } - } -''' -replace_once("src/index.js", old_status_recovery, new_status_recovery) - -# Native compaction tracking and token-safe finalization. -old_clear_active = '''function clearActiveRun(sessionID) { - const active = activeRuns.get(sessionID) - if (active?.timer) clearTimeout(active.timer) - activeRuns.delete(sessionID) -} -''' -new_clear_active = '''function clearActiveRun(sessionID) { - const active = activeRuns.get(sessionID) - if (active?.timer) clearTimeout(active.timer) - const compact = loopCompactionRequests.get(sessionID) - if (!compact || !active || compact.jobId === active.jobId) loopCompactionRequests.delete(sessionID) - activeRuns.delete(sessionID) -} - -function beginLoopCompaction(sessionID, jobId, resumeAfter = false) { - loopCompactionRequests.set(sessionID, { - jobId, - resumeAfter, - requestedAt: now(), - startedAt: 0, - completedAt: 0, - }) -} - -async function noteLoopCompactionStarted(directory, sessionID) { - const pending = loopCompactionRequests.get(sessionID) - if (!pending) return false - if (!pending.startedAt) { - pending.startedAt = now() - loopCompactionRequests.set(sessionID, pending) - await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter }) - } - return true -} - -async function finalizeLoopCompaction(directory, client, sessionID) { - const pending = loopCompactionRequests.get(sessionID) - const active = activeRuns.get(sessionID) - if (!pending || !active || pending.jobId !== active.jobId) return false - if (active.compactionOnly) { - clearActiveRun(sessionID) - sessionStatuses.delete(sessionID) - sessionStatusSeenAt.delete(sessionID) - await appendLoopLog(directory, "compact-finished", { sessionID, job: pending.jobId, resumeAfter: true }) - await scheduleDueWork(directory, client, sessionID) - return true - } - return await finalizeActiveRun(directory, client, sessionID) -} - -async function noteLoopCompactionCompleted(directory, client, sessionID) { - const pending = loopCompactionRequests.get(sessionID) - if (!pending) return false - pending.completedAt = now() - loopCompactionRequests.set(sessionID, pending) - await appendLoopLog(directory, "compact-event", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter }) - const timer = setTimeout(() => { - finalizeLoopCompaction(directory, client, sessionID) - .catch((error) => log(client, "error", "compaction finalization failed", { error: sdkErrorMessage(error) })) - }, 0) - timer.unref?.() - return true -} -''' -replace_once("src/index.js", old_clear_active, new_clear_active) - -# Route explicit compact jobs/commands through the lifecycle tracker and ensure a -# failed compact command does not masquerade as a started assistant turn. -old_fire_compact = ''' if (kind === "compact") { - const ok = await compactSession(client, sessionID) - return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" } - } -''' -new_fire_compact = ''' if (kind === "compact") { - beginLoopCompaction(sessionID, job.id, false) - const ok = await compactSession(directory, client, sessionID, model) - if (!ok) loopCompactionRequests.delete(sessionID) - return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok } - } -''' -replace_once("src/index.js", old_fire_compact, new_fire_compact) - -old_command_compact = ''' const tuiCommand = compactTuiCommandName(command) - if (tuiCommand) { - guardLoopOwnedUserMessage(sessionID) - await compactSession(client, sessionID) - return { startsAssistantTurn: true } - } -''' -new_command_compact = ''' const tuiCommand = compactTuiCommandName(command) - if (tuiCommand) { - guardLoopOwnedUserMessage(sessionID) - beginLoopCompaction(sessionID, job.id, false) - const ok = await compactSession(directory, client, sessionID, model) - if (!ok) loopCompactionRequests.delete(sessionID) - return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok } - } -''' -replace_once("src/index.js", old_command_compact, new_command_compact) - -# Split automatic pre-action compaction into its own active phase so the actual -# prompt/shell action cannot overlap the compaction turn. -old_prepare = ''' job = await ensureBranch(directory, job, client, sessionID) - job = await maybeCompact(client, sessionID, job) - job.watchTriggered = false - job.lastRunAt = now() - job.runCount = (job.runCount || 0) + 1 -''' -new_prepare = ''' job = await ensureBranch(directory, job, client, sessionID) - const compactResult = await maybeCompact(directory, client, sessionID, job) - job = compactResult.job - if (compactResult.started) { - state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate) - await writeState(directory, sessionID, state) - let timer - if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs) - const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}` - activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true }) - const pending = loopCompactionRequests.get(sessionID) - if (pending?.jobId === job.id && pending.completedAt) { - await finalizeLoopCompaction(directory, client, sessionID) - return - } - sessionStatuses.set(sessionID, "busy") - sessionStatusSeenAt.set(sessionID, now()) - await reschedule(BUSY_RETRY_MS) - return - } - job.watchTriggered = false - job.lastRunAt = now() - job.runCount = (job.runCount || 0) + 1 -''' -replace_once("src/index.js", old_prepare, new_prepare) - -old_active_set = ''' activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken }) - if (result.dispatch) { -''' -new_active_set = ''' activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionAction: result.compaction === true }) - if (result.compaction) { - const pending = loopCompactionRequests.get(sessionID) - if (pending?.jobId === job.id && pending.completedAt) { - await finalizeLoopCompaction(directory, client, sessionID) - return - } - } - if (result.dispatch) { -''' -replace_once("src/index.js", old_active_set, new_active_set) - -# Wire the latest native OpenCode compaction hook/event. Older hosts simply never -# invoke the extra hook/event, leaving the existing idle/status fallback intact. -old_hooks = ''' tool: goalTools(directory), - "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) }, - "tool.execute.before": async (input) => { markToolCallActive(input) }, - "tool.execute.after": async (input) => { markToolCallFinished(input) }, - event: async ({ event }) => { -''' -new_hooks = ''' tool: goalTools(directory), - "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) }, - "tool.execute.before": async (input) => { markToolCallActive(input) }, - "tool.execute.after": async (input) => { markToolCallFinished(input) }, - "experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) }, - event: async ({ event }) => { - if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID) -''' -replace_once("src/index.js", old_hooks, new_hooks) - -# Test harness: message history + summarize request capture + TUI-only failure. -replace_once( - "scripts/comprehensive-test.mjs", - ''' shells: [], - toasts: [], - tuiCommands: [], - } - const statuses = new Map([[sessionID, "idle"]]) -''', - ''' shells: [], - summaries: [], - messageReads: [], - toasts: [], - tuiCommands: [], - } - const statuses = new Map([[sessionID, "idle"]]) - const messageHistory = Array.isArray(options.messages) ? structuredClone(options.messages) : [] -''', -) -replace_once( - "scripts/comprehensive-test.mjs", - ''' records.tuiCommands.push(args.body.command) - if (options.failCompact) throw new Error("simulated TUI compact failure") - return { data: true } -''', - ''' records.tuiCommands.push(args.body.command) - if (options.failCompact || options.failTuiCompact) throw new Error("simulated TUI compact failure") - return { data: true } -''', -) -replace_once( - "scripts/comprehensive-test.mjs", - ''' status: async (args) => { - assert.equal(args?.query?.directory, directory) - // Current OpenCode omits idle sessions from this response. - return { - data: Object.fromEntries( - [...statuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }]), - ), - } - }, - summarize: async () => { - if (options.failCompact) throw new Error("simulated summarize failure") - return { data: true } - }, -''', - ''' status: async (args) => { - assert.equal(args?.query?.directory, directory) - // Current OpenCode omits idle sessions from this response. - return { - data: Object.fromEntries( - [...statuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }]), - ), - } - }, - messages: async (args) => { - assert.equal(args?.path?.id, sessionID) - assert.equal(args?.query?.directory, directory) - records.messageReads.push(args) - return { data: structuredClone(messageHistory) } - }, - summarize: async (args) => { - assert.equal(args?.path?.id, sessionID) - records.summaries.push(args?.body) - if (options.failCompact) throw new Error("simulated summarize failure") - return { data: true } - }, -''', -) -replace_once( - "scripts/comprehensive-test.mjs", - ''' records, - sessionID, - stateFile, - statuses, -''', - ''' records, - sessionID, - stateFile, - statuses, - messageHistory, -''', -) - -# Add focused regressions after action routing. -marker = '''async function testPromptDispatchFailureRecovery() { -''' -new_tests = '''async function testNativeCompactionLifecycleAndFallback() { - let h = await createHarness({ failTuiCompact: true }) - try { - await h.command("loop-compact", "0s --no-now") - await h.command("loop-now", "compact") - assert.deepEqual(h.records.summaries[0], { - providerID: "test-provider", - modelID: "test-model", - auto: false, - }, "headless compact fallback must satisfy the current OpenCode summarize payload") - assert.equal(typeof h.hooks["experimental.session.compacting"], "function") - const compactOutput = { context: [], prompt: undefined } - await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput) - assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt") - await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } }) - await delay(20) - const state = await h.readState() - assert.ok(state.jobs[0].lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery") - } finally { - await h.cleanup() - } - - h = await createHarness() - try { - await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 continue after compaction") - const seeded = await h.readState() - seeded.jobs[0].runCount = 1 - seeded.jobs[0].lastRunAt = 0 - await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") - - await h.command("loop-now", "compact-chain") - assert.equal(h.records.tuiCommands.length, 1, "compact-every must start compaction") - assert.equal(h.actionTexts().length, 0, "the scheduled action must not overlap a pending compaction") - assert.equal((await h.readState()).jobs[0].runCount, 1, "compaction-only phase must not count as a normal loop run") - - await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, { context: [], prompt: undefined }) - await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } }) - await delay(20) - assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action") - } finally { - await h.cleanup() - } -} - -async function testStaleBusyUsesCompletedAssistantTail() { - let h = await createHarness() - try { - await h.command("loop", "5m --no-now --name stale-complete continue safely") - await h.command("loop-now", "stale-complete") - h.statuses.set(h.sessionID, "busy") - const completedAt = Date.now() + 5 - h.messageHistory.splice(0, h.messageHistory.length, - { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] }, - { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] }, - ) - await h.command("loop-now", "stale-complete") - const state = await h.readState() - assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status") - assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history") - } finally { - await h.cleanup() - } - - h = await createHarness() - try { - await h.command("loop", "5m --no-now --name stale-incomplete continue safely") - const seeded = await h.readState() - seeded.jobs[0].staleActiveRecoveryMs = 1 - await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") - await h.command("loop-now", "stale-incomplete") - await delay(10) - h.statuses.set(h.sessionID, "busy") - const createdAt = Date.now() - h.messageHistory.splice(0, h.messageHistory.length, - { info: { id: "usr_running", sessionID: h.sessionID, role: "user", time: { created: createdAt - 1 } }, parts: [] }, - { info: { id: "asst_running", sessionID: h.sessionID, role: "assistant", time: { created: createdAt } }, parts: [] }, - ) - await h.command("loop-now", "stale-incomplete") - const state = await h.readState() - assert.equal(state.jobs[0].lastFinishedAt, undefined, "an unfinished assistant tail must never be force-finalized just because the active-run timeout elapsed") - assert.equal(h.actionTexts().length, 1, "unfinished work must not be overlapped by a replacement prompt") - } finally { - await h.cleanup() - } -} - -''' -replace_once("scripts/comprehensive-test.mjs", marker, new_tests + marker) -replace_once( - "scripts/comprehensive-test.mjs", - '''await testActionRoutingAndSafety() -await testPromptDispatchFailureRecovery() -''', - '''await testActionRoutingAndSafety() -await testNativeCompactionLifecycleAndFallback() -await testStaleBusyUsesCompletedAssistantTail() -await testPromptDispatchFailureRecovery() -''', -) - -# Release metadata/docs. -replace_once("package.json", '"version": "0.5.23"', '"version": "0.5.24"') -replace_once( - "CHANGELOG.md", - "# Changelog\n\n", - "# Changelog\n\n## 0.5.24\n\n- Cross-check stale OpenCode `busy`/`retry` status against the chronological session tail and only recover early when the latest assistant message has a real `time.completed`; an explicitly unfinished assistant tail is never force-finalized.\n- Track scheduled compaction through OpenCode's native `experimental.session.compacting` hook and `session.compacted` event, while retaining idle/status fallbacks for older hosts.\n- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction.\n- Fix headless/server compact fallback for current OpenCode by supplying the required `providerID`, `modelID`, and `auto: false` payload to `session.summarize`.\n- Add deterministic regressions for completed-vs-running assistant tails, native compaction completion, compact/action serialization, and current summarize payloads.\n\n", -) -replace_once( - "README.md", - "**v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.**", - "**v0.5.24 uses OpenCode's native compaction lifecycle, serializes compact-before-run work, fixes current headless summarize payloads, and validates stale `busy` recovery against a genuinely completed assistant tail.** **v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.**", -) - -print("v0.5.24 patch applied") diff --git a/scripts/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index e3c8e9e6..46b8b61d 100644 --- a/scripts/comprehensive-test.mjs +++ b/scripts/comprehensive-test.mjs @@ -18,10 +18,13 @@ async function createHarness(options = {}) { logs: [], prompts: [], shells: [], + summaries: [], + messageReads: [], toasts: [], tuiCommands: [], } const statuses = new Map([[sessionID, "idle"]]) + const messageHistory = Array.isArray(options.messages) ? structuredClone(options.messages) : [] const client = { app: { @@ -35,7 +38,7 @@ async function createHarness(options = {}) { executeCommand: async (args) => { assert.ok(args?.body?.command, "tui.executeCommand must use the SDK body shape") records.tuiCommands.push(args.body.command) - if (options.failCompact) throw new Error("simulated TUI compact failure") + if (options.failCompact || options.failTuiCompact) throw new Error("simulated TUI compact failure") return { data: true } }, showToast: async (args) => { @@ -95,7 +98,15 @@ async function createHarness(options = {}) { ), } }, - summarize: async () => { + messages: async (args) => { + assert.equal(args?.path?.id, sessionID) + assert.equal(args?.query?.directory, directory) + records.messageReads.push(args) + return { data: structuredClone(messageHistory) } + }, + summarize: async (args) => { + assert.equal(args?.path?.id, sessionID) + records.summaries.push(args?.body) if (options.failCompact) throw new Error("simulated summarize failure") return { data: true } }, @@ -112,6 +123,7 @@ async function createHarness(options = {}) { sessionID, stateFile, statuses, + messageHistory, async command(command, argumentsText = "", output = { parts: [] }) { await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output) return output @@ -378,6 +390,92 @@ async function testActionRoutingAndSafety() { } } +async function testNativeCompactionLifecycleAndFallback() { + let h = await createHarness({ failTuiCompact: true }) + try { + await h.command("loop-compact", "0s --no-now") + await h.command("loop-now", "compact") + assert.deepEqual(h.records.summaries[0], { + providerID: "test-provider", + modelID: "test-model", + auto: false, + }, "headless compact fallback must satisfy the current OpenCode summarize payload") + assert.equal(typeof h.hooks["experimental.session.compacting"], "function") + const compactOutput = { context: [], prompt: undefined } + await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput) + assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt") + await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } }) + await delay(20) + const state = await h.readState() + assert.ok(state.jobs[0].lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery") + } finally { + await h.cleanup() + } + + h = await createHarness() + try { + await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 continue after compaction") + const seeded = await h.readState() + seeded.jobs[0].runCount = 1 + seeded.jobs[0].lastRunAt = 0 + await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") + + await h.command("loop-now", "compact-chain") + assert.equal(h.records.tuiCommands.length, 1, "compact-every must start compaction") + assert.equal(h.actionTexts().length, 0, "the scheduled action must not overlap a pending compaction") + assert.equal((await h.readState()).jobs[0].runCount, 1, "compaction-only phase must not count as a normal loop run") + + await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, { context: [], prompt: undefined }) + await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } }) + await delay(20) + assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action") + } finally { + await h.cleanup() + } +} + +async function testStaleBusyUsesCompletedAssistantTail() { + let h = await createHarness() + try { + await h.command("loop", "5m --no-now --name stale-complete continue safely") + await h.command("loop-now", "stale-complete") + h.statuses.set(h.sessionID, "busy") + const completedAt = Date.now() + 5 + h.messageHistory.splice(0, h.messageHistory.length, + { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] }, + { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] }, + ) + await h.command("loop-now", "stale-complete") + const state = await h.readState() + assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status") + assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history") + } finally { + await h.cleanup() + } + + h = await createHarness() + try { + await h.command("loop", "5m --no-now --name stale-incomplete continue safely") + const seeded = await h.readState() + seeded.jobs[0].staleActiveRecoveryMs = 1 + await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") + await h.command("loop-now", "stale-incomplete") + await delay(10) + h.statuses.set(h.sessionID, "busy") + const createdAt = Date.now() + h.messageHistory.splice(0, h.messageHistory.length, + { info: { id: "usr_running", sessionID: h.sessionID, role: "user", time: { created: createdAt - 1 } }, parts: [] }, + { info: { id: "asst_running", sessionID: h.sessionID, role: "assistant", time: { created: createdAt } }, parts: [] }, + ) + await h.command("loop-now", "stale-incomplete") + const state = await h.readState() + assert.equal(state.jobs[0].lastFinishedAt, undefined, "an unfinished assistant tail must never be force-finalized just because the active-run timeout elapsed") + assert.equal(h.actionTexts().length, 1, "unfinished work must not be overlapped by a replacement prompt") + } finally { + await h.cleanup() + } +} + async function testPromptDispatchFailureRecovery() { const h = await createHarness({ failPrompt: true, promptFailureDelayMs: 25 }) try { @@ -593,6 +691,8 @@ await testParserAndPresets() await testLifecycleAndCommandDedupe() await testWatchScheduling() await testActionRoutingAndSafety() +await testNativeCompactionLifecycleAndFallback() +await testStaleBusyUsesCompletedAssistantTail() await testPromptDispatchFailureRecovery() await testStopsPreflightAndGoalLifecycle() await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt() diff --git a/src/index.js b/src/index.js index aafdfb53..f4a8b87c 100644 --- a/src/index.js +++ b/src/index.js @@ -40,6 +40,7 @@ const sessionParents = new Map() let heartbeatTimer const sessionStatuses = new Map() const sessionStatusSeenAt = new Map() +const loopCompactionRequests = new Map() const DEFAULT_PROGRESS_MD = `# Progress @@ -532,11 +533,72 @@ function compactTuiCommandName(command = "compact") { return undefined } -async function compactSession(client, sessionID) { - // OpenCode's TUI API accepts legacy keybind aliases (session_compact) in - // current builds, while some older docs/examples mention the event value - // (session.compact). Try the alias first, then the event value, then the - // session summarize endpoint as a last resort. +async function readRecentSessionMessages(client, sessionID, directory, limit = 20) { + if (!client?.session?.messages) return undefined + const query = { limit } + if (directory) query.directory = directory + try { + const messages = await sdkCall( + client.session.messages.bind(client.session), + { path: { id: sessionID }, query }, + { path: { sessionID }, query }, + { sessionID, ...query }, + ) + return Array.isArray(messages) ? messages : undefined + } catch { + return undefined + } +} + +function orderedSessionMessages(messages) { + return (messages || []) + .map((message, index) => { + const info = message?.info || message || {} + const created = Number(info?.time?.created || 0) + return { message, index, created: Number.isFinite(created) ? created : 0 } + }) + .sort((a, b) => a.created - b.created || a.index - b.index) + .map((entry) => entry.message) +} + +async function activeRunCompletionFromMessages(directory, client, sessionID, active) { + const messages = await readRecentSessionMessages(client, sessionID, directory) + if (!messages) return "unknown" + const tail = orderedSessionMessages(messages).at(-1) + const info = tail?.info || tail + if (!info || info.role !== "assistant") return "incomplete" + const completed = Number(info?.time?.completed || 0) + const created = Number(info?.time?.created || 0) + if (!Number.isFinite(completed) || completed <= 0) return "incomplete" + const startedAt = Number(active?.startedAt || 0) + if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete" + return "completed" +} + +async function resolveCompactionModel(directory, client, sessionID, preferredModel) { + const preferred = normalizedModelRef(preferredModel) + if (preferred) return preferred + const cached = normalizedModelRef(sessionExecutionContexts.get(sessionID)?.model) + if (cached) return cached + const captured = await captureSessionExecutionContext(client, sessionID) + const capturedModel = normalizedModelRef(captured?.model) + if (capturedModel) return capturedModel + const messages = await readRecentSessionMessages(client, sessionID, directory) + for (const message of orderedSessionMessages(messages).reverse()) { + const info = message?.info || message + const model = normalizedModelRef(info?.model) || normalizedModelRef(info) + if (!model) continue + const previous = sessionExecutionContexts.get(sessionID) || {} + sessionExecutionContexts.set(sessionID, { ...previous, model }) + return model + } + return undefined +} + +async function compactSession(directory, client, sessionID, preferredModel) { + // Prefer the native TUI command when a TUI is present. Headless/server hosts + // fall back to session.summarize, whose current API requires an explicit + // provider/model pair. for (const command of ["session.compact", "session_compact"]) { try { await executeTuiCommand(client, command) @@ -546,17 +608,21 @@ async function compactSession(client, sessionID) { } } try { + if (!client?.session?.summarize) throw new Error("client.session.summarize is not available") + const model = await resolveCompactionModel(directory, client, sessionID, preferredModel) + if (!model) throw new Error("could not resolve a provider/model for session.summarize") + const body = { providerID: model.providerID, modelID: model.modelID, auto: false } await sdkCall( client.session.summarize.bind(client.session), - { path: { id: sessionID }, body: {} }, - { path: { sessionID }, body: {} }, - { sessionID }, + { path: { id: sessionID }, body }, + { path: { sessionID }, body }, + { sessionID, ...body }, ) return true } catch (error) { await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) }) } - await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error") + await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error") return false } @@ -850,6 +916,7 @@ function disposeRuntime(directory, client) { sessionStatuses.delete(sessionID) sessionStatusSeenAt.delete(sessionID) sessionExecutionContexts.delete(sessionID) + loopCompactionRequests.delete(sessionID) for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key) for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key) } @@ -1079,15 +1146,18 @@ async function ensureBranch(directory, job, client, sessionID) { return job } -async function maybeCompact(client, sessionID, job) { +async function maybeCompact(directory, client, sessionID, job) { const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs) - if (!dueRuns && !dueTime) return job - if (await compactSession(client, sessionID)) { + if (!dueRuns && !dueTime) return { job, started: false } + beginLoopCompaction(sessionID, job.id, true) + if (await compactSession(directory, client, sessionID, job.model)) { job.lastCompactAt = now() job.lastCompactRunCount = job.runCount || 0 + return { job, started: true } } - return job + loopCompactionRequests.delete(sessionID) + return { job, started: false } } async function snapshotPaths(directory, files) { @@ -1226,12 +1296,21 @@ function staleActiveRun(sessionID) { async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) { if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false if (!options.requireIdle && !options.forceStale) return true - if (options.forceStale && staleActiveRun(sessionID)) return true - if (!options.requireIdle) return false + + const completion = options.forceStale + ? await activeRunCompletionFromMessages(directory, client, sessionID, active) + : undefined + if (completion === "completed") return true + if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID) const live = await readLiveSessionStatus(client, sessionID, directory) - if (live?.type) return live.type === "idle" + if (live?.type === "idle") return true + if (live?.type) { + if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true + return false + } + if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true const cached = sessionStatuses.get(sessionID) const seenAt = sessionStatusSeenAt.get(sessionID) || 0 return cached === "idle" && seenAt > (active.startedAt || 0) @@ -1299,10 +1378,21 @@ async function sessionStatusType(client, sessionID, directory, options = {}) { // plugin-injected turn until the next user command touches the session. // When the only reason we still think the session is busy is our own stale // active-run guard, recover instead of waiting for another manual command. - if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) { - sessionStatuses.set(sessionID, "idle") - sessionStatusSeenAt.set(sessionID, now()) - return "idle" + if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) { + const active = activeRuns.get(sessionID) + if (active) { + const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active) + if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) { + sessionStatuses.set(sessionID, "idle") + sessionStatusSeenAt.set(sessionID, now()) + await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", { + sessionID, + job: active.job?.name || active.jobId, + startedAt: active.startedAt, + }) + return "idle" + } + } } sessionStatuses.set(sessionID, live.type) sessionStatusSeenAt.set(sessionID, now()) @@ -1448,9 +1538,61 @@ function dueJobs(state, force = false) { function clearActiveRun(sessionID) { const active = activeRuns.get(sessionID) if (active?.timer) clearTimeout(active.timer) + const compact = loopCompactionRequests.get(sessionID) + if (!compact || !active || compact.jobId === active.jobId) loopCompactionRequests.delete(sessionID) activeRuns.delete(sessionID) } +function beginLoopCompaction(sessionID, jobId, resumeAfter = false) { + loopCompactionRequests.set(sessionID, { + jobId, + resumeAfter, + requestedAt: now(), + startedAt: 0, + completedAt: 0, + }) +} + +async function noteLoopCompactionStarted(directory, sessionID) { + const pending = loopCompactionRequests.get(sessionID) + if (!pending) return false + if (!pending.startedAt) { + pending.startedAt = now() + loopCompactionRequests.set(sessionID, pending) + await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter }) + } + return true +} + +async function finalizeLoopCompaction(directory, client, sessionID) { + const pending = loopCompactionRequests.get(sessionID) + const active = activeRuns.get(sessionID) + if (!pending || !active || pending.jobId !== active.jobId) return false + if (active.compactionOnly) { + clearActiveRun(sessionID) + sessionStatuses.delete(sessionID) + sessionStatusSeenAt.delete(sessionID) + await appendLoopLog(directory, "compact-finished", { sessionID, job: pending.jobId, resumeAfter: true }) + await scheduleDueWork(directory, client, sessionID) + return true + } + return await finalizeActiveRun(directory, client, sessionID) +} + +async function noteLoopCompactionCompleted(directory, client, sessionID) { + const pending = loopCompactionRequests.get(sessionID) + if (!pending) return false + pending.completedAt = now() + loopCompactionRequests.set(sessionID, pending) + await appendLoopLog(directory, "compact-event", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter }) + const timer = setTimeout(() => { + finalizeLoopCompaction(directory, client, sessionID) + .catch((error) => log(client, "error", "compaction finalization failed", { error: sdkErrorMessage(error) })) + }, 0) + timer.unref?.() + return true +} + async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) { const active = activeRuns.get(sessionID) if (!active || active.jobId !== jobId || active.runToken !== runToken) return false @@ -1781,8 +1923,10 @@ async function fireAction(directory, client, sessionID, job) { const agent = job.agent || "build" const model = normalizedModelRef(job.model) if (kind === "compact") { - const ok = await compactSession(client, sessionID) - return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" } + beginLoopCompaction(sessionID, job.id, false) + const ok = await compactSession(directory, client, sessionID, model) + if (!ok) loopCompactionRequests.delete(sessionID) + return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok } } if (kind === "command") { const normalized = action.startsWith("/") ? action.slice(1) : action @@ -1794,8 +1938,10 @@ async function fireAction(directory, client, sessionID, job) { const tuiCommand = compactTuiCommandName(command) if (tuiCommand) { guardLoopOwnedUserMessage(sessionID) - await compactSession(client, sessionID) - return { startsAssistantTurn: true } + beginLoopCompaction(sessionID, job.id, false) + const ok = await compactSession(directory, client, sessionID, model) + if (!ok) loopCompactionRequests.delete(sessionID) + return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok } } guardLoopOwnedUserMessage(sessionID) const commandBody = { command, arguments: argumentsText, agent } @@ -1939,7 +2085,25 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) { } job = await ensureBranch(directory, job, client, sessionID) - job = await maybeCompact(client, sessionID, job) + const compactResult = await maybeCompact(directory, client, sessionID, job) + job = compactResult.job + if (compactResult.started) { + state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate) + await writeState(directory, sessionID, state) + let timer + if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs) + const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}` + activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true }) + const pending = loopCompactionRequests.get(sessionID) + if (pending?.jobId === job.id && pending.completedAt) { + await finalizeLoopCompaction(directory, client, sessionID) + return + } + sessionStatuses.set(sessionID, "busy") + sessionStatusSeenAt.set(sessionID, now()) + await reschedule(BUSY_RETRY_MS) + return + } job.watchTriggered = false job.lastRunAt = now() job.runCount = (job.runCount || 0) + 1 @@ -1972,7 +2136,14 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) { let timer if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs) const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}` - activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken }) + activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionAction: result.compaction === true }) + if (result.compaction) { + const pending = loopCompactionRequests.get(sessionID) + if (pending?.jobId === job.id && pending.completedAt) { + await finalizeLoopCompaction(directory, client, sessionID) + return + } + } if (result.dispatch) { void result.dispatch.catch((error) => { recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error) @@ -2344,7 +2515,9 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => { "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) }, "tool.execute.before": async (input) => { markToolCallActive(input) }, "tool.execute.after": async (input) => { markToolCallFinished(input) }, + "experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) }, event: async ({ event }) => { + if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID) updateSessionRelationshipFromEvent(event) if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info) updateToolActivityFromEvent(event) From 7a4bf34edd7b34a6e903471fa4a609ea49ea7328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sat, 8 Aug 2026 20:06:21 +0300 Subject: [PATCH 4/9] chore: remove temporary v0.5.24 patch workflow --- .github/workflows/apply-v0524-pr.yml | 44 ---------------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/apply-v0524-pr.yml diff --git a/.github/workflows/apply-v0524-pr.yml b/.github/workflows/apply-v0524-pr.yml deleted file mode 100644 index 5890c702..00000000 --- a/.github/workflows/apply-v0524-pr.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Apply v0.5.24 on PR - -on: - pull_request: - branches: - - main - -permissions: - contents: write - -jobs: - apply: - if: github.head_ref == 'fix/v0.5.24-native-compaction-status' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - - uses: actions/setup-node@v6 - with: - node-version: "24" - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Apply v0.5.24 patch - run: python scripts/apply-v0524.py - - name: Refresh lockfile - run: npm install --package-lock-only --ignore-scripts - - name: Verify candidate - run: | - npm ci - npm run check - npm test - bun -e "await import('./src/index.js')" - npm pack --dry-run - - name: Commit verified candidate - shell: bash - run: | - rm scripts/apply-v0524.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/index.js scripts/comprehensive-test.mjs package.json package-lock.json CHANGELOG.md README.md scripts/apply-v0524.py - git commit -m "fix: harden native compaction and stale status recovery" - git push origin HEAD:${{ github.head_ref }} From 2994dbb3a1962c472c0fea3f2e0ed442e54e41f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sat, 8 Aug 2026 20:09:43 +0300 Subject: [PATCH 5/9] chore: stage compaction-only fallback fix --- scripts/apply-v0524-followup.py | 127 ++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 scripts/apply-v0524-followup.py diff --git a/scripts/apply-v0524-followup.py b/scripts/apply-v0524-followup.py new file mode 100644 index 00000000..8a0e89cf --- /dev/null +++ b/scripts/apply-v0524-followup.py @@ -0,0 +1,127 @@ +from pathlib import Path + + +def replace_once(path, old, new): + p = Path(path) + text = p.read_text() + if old not in text: + raise SystemExit(f"missing expected block in {path}") + if text.count(old) != 1: + raise SystemExit(f"expected one match in {path}, found {text.count(old)}") + p.write_text(text.replace(old, new, 1)) + + +replace_once( + "src/index.js", + '''async function finalizeLoopCompaction(directory, client, sessionID) { + const pending = loopCompactionRequests.get(sessionID) + const active = activeRuns.get(sessionID) + if (!pending || !active || pending.jobId !== active.jobId) return false + if (active.compactionOnly) { + clearActiveRun(sessionID) + sessionStatuses.delete(sessionID) + sessionStatusSeenAt.delete(sessionID) + await appendLoopLog(directory, "compact-finished", { sessionID, job: pending.jobId, resumeAfter: true }) + await scheduleDueWork(directory, client, sessionID) + return true + } + return await finalizeActiveRun(directory, client, sessionID) +} +''', + '''async function finalizeLoopCompaction(directory, client, sessionID) { + const pending = loopCompactionRequests.get(sessionID) + const active = activeRuns.get(sessionID) + if (!pending || !active || pending.jobId !== active.jobId) return false + return await finalizeActiveRun(directory, client, sessionID) +} +''', +) + +replace_once( + "src/index.js", + '''async function finalizeActiveRun(directory, client, sessionID, options = {}) { + const active = activeRuns.get(sessionID) + if (!active) return + if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false + const recoveredStale = staleActiveRun(sessionID) + clearActiveRun(sessionID) + const state = await readState(directory, sessionID) +''', + '''async function finalizeActiveRun(directory, client, sessionID, options = {}) { + const active = activeRuns.get(sessionID) + if (!active) return + if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false + const recoveredStale = staleActiveRun(sessionID) + if (active.compactionOnly) { + const pending = loopCompactionRequests.get(sessionID) + clearActiveRun(sessionID) + sessionStatuses.delete(sessionID) + sessionStatusSeenAt.delete(sessionID) + await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", { + sessionID, + job: active.job?.name || active.jobId, + startedAt: active.startedAt, + nativeEvent: Boolean(pending?.completedAt), + }) + await scheduleDueWork(directory, client, sessionID) + return true + } + clearActiveRun(sessionID) + const state = await readState(directory, sessionID) +''', +) + +# Make the fallback regression prove that a compaction-only phase cannot run +# verify/postrun logic even when an older host only reports idle and never emits +# session.compacted. +needle = ''' h = await createHarness() + try { + await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 continue after compaction") +''' +replacement = ''' h = await createHarness() + try { + await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 --verify \"node -e process.exitCode=7\" --pause-on-verify-fail continue after compaction") +''' +replace_once("scripts/comprehensive-test.mjs", needle, replacement) + +replace_once( + "scripts/comprehensive-test.mjs", + ''' assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action") + } finally { + await h.cleanup() + } +} +''', + ''' assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action") + } finally { + await h.cleanup() + } + + h = await createHarness() + try { + await h.command("loop", "5m --no-now --name compact-idle-fallback --compact-every 1 --verify \"node -e process.exitCode=7\" --pause-on-verify-fail continue after fallback compaction") + const seeded = await h.readState() + seeded.jobs[0].runCount = 1 + seeded.jobs[0].lastRunAt = 0 + await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") + await h.command("loop-now", "compact-idle-fallback") + assert.equal(h.actionTexts().length, 0) + h.statuses.set(h.sessionID, "idle") + await h.command("loop-now", "compact-idle-fallback") + const fallbackState = await h.readState() + assert.equal(fallbackState.jobs[0].failureCount || 0, 0, "idle-only compaction fallback must not run normal verify logic") + assert.equal(fallbackState.jobs[0].paused, false, "idle-only compaction fallback must not pause the job through normal run finalization") + } finally { + await h.cleanup() + } +} +''', +) + +replace_once( + "CHANGELOG.md", + "- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction.\n", + "- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction; older hosts that only report idle also finalize this phase without running normal verify/postrun hooks.\n", +) + +print("v0.5.24 follow-up applied") From bd6ffe00a52d17cc57b89b6b6deae7bd8a1f8998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sat, 8 Aug 2026 20:09:53 +0300 Subject: [PATCH 6/9] ci: verify v0.5.24 compaction fallback follow-up --- .github/workflows/apply-v0524-followup-pr.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/apply-v0524-followup-pr.yml diff --git a/.github/workflows/apply-v0524-followup-pr.yml b/.github/workflows/apply-v0524-followup-pr.yml new file mode 100644 index 00000000..758ee066 --- /dev/null +++ b/.github/workflows/apply-v0524-followup-pr.yml @@ -0,0 +1,42 @@ +name: Apply v0.5.24 follow-up on PR + +on: + pull_request: + branches: + - main + +permissions: + contents: write + +jobs: + apply: + if: github.head_ref == 'fix/v0.5.24-native-compaction-status' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + - uses: actions/setup-node@v6 + with: + node-version: "24" + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Apply compaction fallback follow-up + run: python scripts/apply-v0524-followup.py + - name: Verify candidate + run: | + npm ci + npm run check + npm test + bun -e "await import('./src/index.js')" + npm pack --dry-run + - name: Commit verified follow-up + shell: bash + run: | + rm scripts/apply-v0524-followup.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/index.js scripts/comprehensive-test.mjs CHANGELOG.md scripts/apply-v0524-followup.py + git commit -m "fix: keep compaction-only fallback isolated" + git push origin HEAD:${{ github.head_ref }} From 7457f321c4fc118a9dd7c6923424c5c03a79ca0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sat, 8 Aug 2026 20:10:52 +0300 Subject: [PATCH 7/9] fix: quote v0.5.24 fallback regressions safely --- scripts/apply-v0524-followup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/apply-v0524-followup.py b/scripts/apply-v0524-followup.py index 8a0e89cf..8be4d2c9 100644 --- a/scripts/apply-v0524-followup.py +++ b/scripts/apply-v0524-followup.py @@ -80,7 +80,7 @@ def replace_once(path, old, new): ''' replacement = ''' h = await createHarness() try { - await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 --verify \"node -e process.exitCode=7\" --pause-on-verify-fail continue after compaction") + await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after compaction") ''' replace_once("scripts/comprehensive-test.mjs", needle, replacement) @@ -99,7 +99,7 @@ def replace_once(path, old, new): h = await createHarness() try { - await h.command("loop", "5m --no-now --name compact-idle-fallback --compact-every 1 --verify \"node -e process.exitCode=7\" --pause-on-verify-fail continue after fallback compaction") + await h.command("loop", "5m --no-now --name compact-idle-fallback --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after fallback compaction") const seeded = await h.readState() seeded.jobs[0].runCount = 1 seeded.jobs[0].lastRunAt = 0 From 552fef820279e41ee048b6e8bad6a42150e4338e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:11:24 +0000 Subject: [PATCH 8/9] fix: keep compaction-only fallback isolated --- CHANGELOG.md | 2 +- scripts/apply-v0524-followup.py | 127 -------------------------------- scripts/comprehensive-test.mjs | 20 ++++- src/index.js | 22 ++++-- 4 files changed, 34 insertions(+), 137 deletions(-) delete mode 100644 scripts/apply-v0524-followup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9279ed..a811ed3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Cross-check stale OpenCode `busy`/`retry` status against the chronological session tail and only recover early when the latest assistant message has a real `time.completed`; an explicitly unfinished assistant tail is never force-finalized. - Track scheduled compaction through OpenCode's native `experimental.session.compacting` hook and `session.compacted` event, while retaining idle/status fallbacks for older hosts. -- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction. +- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction; older hosts that only report idle also finalize this phase without running normal verify/postrun hooks. - Fix headless/server compact fallback for current OpenCode by supplying the required `providerID`, `modelID`, and `auto: false` payload to `session.summarize`. - Add deterministic regressions for completed-vs-running assistant tails, native compaction completion, compact/action serialization, and current summarize payloads. diff --git a/scripts/apply-v0524-followup.py b/scripts/apply-v0524-followup.py deleted file mode 100644 index 8be4d2c9..00000000 --- a/scripts/apply-v0524-followup.py +++ /dev/null @@ -1,127 +0,0 @@ -from pathlib import Path - - -def replace_once(path, old, new): - p = Path(path) - text = p.read_text() - if old not in text: - raise SystemExit(f"missing expected block in {path}") - if text.count(old) != 1: - raise SystemExit(f"expected one match in {path}, found {text.count(old)}") - p.write_text(text.replace(old, new, 1)) - - -replace_once( - "src/index.js", - '''async function finalizeLoopCompaction(directory, client, sessionID) { - const pending = loopCompactionRequests.get(sessionID) - const active = activeRuns.get(sessionID) - if (!pending || !active || pending.jobId !== active.jobId) return false - if (active.compactionOnly) { - clearActiveRun(sessionID) - sessionStatuses.delete(sessionID) - sessionStatusSeenAt.delete(sessionID) - await appendLoopLog(directory, "compact-finished", { sessionID, job: pending.jobId, resumeAfter: true }) - await scheduleDueWork(directory, client, sessionID) - return true - } - return await finalizeActiveRun(directory, client, sessionID) -} -''', - '''async function finalizeLoopCompaction(directory, client, sessionID) { - const pending = loopCompactionRequests.get(sessionID) - const active = activeRuns.get(sessionID) - if (!pending || !active || pending.jobId !== active.jobId) return false - return await finalizeActiveRun(directory, client, sessionID) -} -''', -) - -replace_once( - "src/index.js", - '''async function finalizeActiveRun(directory, client, sessionID, options = {}) { - const active = activeRuns.get(sessionID) - if (!active) return - if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false - const recoveredStale = staleActiveRun(sessionID) - clearActiveRun(sessionID) - const state = await readState(directory, sessionID) -''', - '''async function finalizeActiveRun(directory, client, sessionID, options = {}) { - const active = activeRuns.get(sessionID) - if (!active) return - if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false - const recoveredStale = staleActiveRun(sessionID) - if (active.compactionOnly) { - const pending = loopCompactionRequests.get(sessionID) - clearActiveRun(sessionID) - sessionStatuses.delete(sessionID) - sessionStatusSeenAt.delete(sessionID) - await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", { - sessionID, - job: active.job?.name || active.jobId, - startedAt: active.startedAt, - nativeEvent: Boolean(pending?.completedAt), - }) - await scheduleDueWork(directory, client, sessionID) - return true - } - clearActiveRun(sessionID) - const state = await readState(directory, sessionID) -''', -) - -# Make the fallback regression prove that a compaction-only phase cannot run -# verify/postrun logic even when an older host only reports idle and never emits -# session.compacted. -needle = ''' h = await createHarness() - try { - await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 continue after compaction") -''' -replacement = ''' h = await createHarness() - try { - await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after compaction") -''' -replace_once("scripts/comprehensive-test.mjs", needle, replacement) - -replace_once( - "scripts/comprehensive-test.mjs", - ''' assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action") - } finally { - await h.cleanup() - } -} -''', - ''' assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action") - } finally { - await h.cleanup() - } - - h = await createHarness() - try { - await h.command("loop", "5m --no-now --name compact-idle-fallback --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after fallback compaction") - const seeded = await h.readState() - seeded.jobs[0].runCount = 1 - seeded.jobs[0].lastRunAt = 0 - await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") - await h.command("loop-now", "compact-idle-fallback") - assert.equal(h.actionTexts().length, 0) - h.statuses.set(h.sessionID, "idle") - await h.command("loop-now", "compact-idle-fallback") - const fallbackState = await h.readState() - assert.equal(fallbackState.jobs[0].failureCount || 0, 0, "idle-only compaction fallback must not run normal verify logic") - assert.equal(fallbackState.jobs[0].paused, false, "idle-only compaction fallback must not pause the job through normal run finalization") - } finally { - await h.cleanup() - } -} -''', -) - -replace_once( - "CHANGELOG.md", - "- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction.\n", - "- Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction; older hosts that only report idle also finalize this phase without running normal verify/postrun hooks.\n", -) - -print("v0.5.24 follow-up applied") diff --git a/scripts/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index 46b8b61d..4118223e 100644 --- a/scripts/comprehensive-test.mjs +++ b/scripts/comprehensive-test.mjs @@ -414,7 +414,7 @@ async function testNativeCompactionLifecycleAndFallback() { h = await createHarness() try { - await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 continue after compaction") + await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after compaction") const seeded = await h.readState() seeded.jobs[0].runCount = 1 seeded.jobs[0].lastRunAt = 0 @@ -432,6 +432,24 @@ async function testNativeCompactionLifecycleAndFallback() { } finally { await h.cleanup() } + + h = await createHarness() + try { + await h.command("loop", "5m --no-now --name compact-idle-fallback --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after fallback compaction") + const seeded = await h.readState() + seeded.jobs[0].runCount = 1 + seeded.jobs[0].lastRunAt = 0 + await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8") + await h.command("loop-now", "compact-idle-fallback") + assert.equal(h.actionTexts().length, 0) + h.statuses.set(h.sessionID, "idle") + await h.command("loop-now", "compact-idle-fallback") + const fallbackState = await h.readState() + assert.equal(fallbackState.jobs[0].failureCount || 0, 0, "idle-only compaction fallback must not run normal verify logic") + assert.equal(fallbackState.jobs[0].paused, false, "idle-only compaction fallback must not pause the job through normal run finalization") + } finally { + await h.cleanup() + } } async function testStaleBusyUsesCompletedAssistantTail() { diff --git a/src/index.js b/src/index.js index f4a8b87c..3f0acceb 100644 --- a/src/index.js +++ b/src/index.js @@ -1568,14 +1568,6 @@ async function finalizeLoopCompaction(directory, client, sessionID) { const pending = loopCompactionRequests.get(sessionID) const active = activeRuns.get(sessionID) if (!pending || !active || pending.jobId !== active.jobId) return false - if (active.compactionOnly) { - clearActiveRun(sessionID) - sessionStatuses.delete(sessionID) - sessionStatusSeenAt.delete(sessionID) - await appendLoopLog(directory, "compact-finished", { sessionID, job: pending.jobId, resumeAfter: true }) - await scheduleDueWork(directory, client, sessionID) - return true - } return await finalizeActiveRun(directory, client, sessionID) } @@ -1860,6 +1852,20 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) { if (!active) return if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false const recoveredStale = staleActiveRun(sessionID) + if (active.compactionOnly) { + const pending = loopCompactionRequests.get(sessionID) + clearActiveRun(sessionID) + sessionStatuses.delete(sessionID) + sessionStatusSeenAt.delete(sessionID) + await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", { + sessionID, + job: active.job?.name || active.jobId, + startedAt: active.startedAt, + nativeEvent: Boolean(pending?.completedAt), + }) + await scheduleDueWork(directory, client, sessionID) + return true + } clearActiveRun(sessionID) const state = await readState(directory, sessionID) let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId) From 424f3c8b5425d7b6c1ee761c09b70045af73aef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sat, 8 Aug 2026 20:11:50 +0300 Subject: [PATCH 9/9] chore: remove temporary v0.5.24 follow-up workflow --- .github/workflows/apply-v0524-followup-pr.yml | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/apply-v0524-followup-pr.yml diff --git a/.github/workflows/apply-v0524-followup-pr.yml b/.github/workflows/apply-v0524-followup-pr.yml deleted file mode 100644 index 758ee066..00000000 --- a/.github/workflows/apply-v0524-followup-pr.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Apply v0.5.24 follow-up on PR - -on: - pull_request: - branches: - - main - -permissions: - contents: write - -jobs: - apply: - if: github.head_ref == 'fix/v0.5.24-native-compaction-status' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - - uses: actions/setup-node@v6 - with: - node-version: "24" - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Apply compaction fallback follow-up - run: python scripts/apply-v0524-followup.py - - name: Verify candidate - run: | - npm ci - npm run check - npm test - bun -e "await import('./src/index.js')" - npm pack --dry-run - - name: Commit verified follow-up - shell: bash - run: | - rm scripts/apply-v0524-followup.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/index.js scripts/comprehensive-test.mjs CHANGELOG.md scripts/apply-v0524-followup.py - git commit -m "fix: keep compaction-only fallback isolated" - git push origin HEAD:${{ github.head_ref }}