Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
122 changes: 120 additions & 2 deletions scripts/comprehensive-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 }
},
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -593,6 +709,8 @@ await testParserAndPresets()
await testLifecycleAndCommandDedupe()
await testWatchScheduling()
await testActionRoutingAndSafety()
await testNativeCompactionLifecycleAndFallback()
await testStaleBusyUsesCompletedAssistantTail()
await testPromptDispatchFailureRecovery()
await testStopsPreflightAndGoalLifecycle()
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
Expand Down
Loading