diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c089b68..a811ed3e 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; 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. + ## 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/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index e3c8e9e6..4118223e 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,110 @@ 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 --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 + 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() + } + + 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() { + 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 +709,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..3f0acceb 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,53 @@ 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 + 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 @@ -1718,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) @@ -1781,8 +1929,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 +1944,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 +2091,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 +2142,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 +2521,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)