@bybrawe/opencode-loop 0.5.22 → 0.5.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/comprehensive-test.mjs +151 -2
- package/src/index.js +251 -33
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.24
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Track scheduled compaction through OpenCode's native `experimental.session.compacting` hook and `session.compacted` event, while retaining idle/status fallbacks for older hosts.
|
|
7
|
+
- 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.
|
|
8
|
+
- Fix headless/server compact fallback for current OpenCode by supplying the required `providerID`, `modelID`, and `auto: false` payload to `session.summarize`.
|
|
9
|
+
- Add deterministic regressions for completed-vs-running assistant tails, native compaction completion, compact/action serialization, and current summarize payloads.
|
|
10
|
+
|
|
11
|
+
## 0.5.23
|
|
12
|
+
|
|
13
|
+
- Track non-blocking `session.prompt` and `session.shell` dispatch promises instead of treating every fire-and-forget request as successfully started.
|
|
14
|
+
- Recover a rejected scheduler dispatch immediately: clear only the matching active run token, invalidate cached busy status, persist the failure, honor `--max-failures`, and reschedule through the normal scheduler path.
|
|
15
|
+
- Never replay a rejected prompt automatically; this avoids duplicate turns when OpenCode status/events are delayed or multiple instances are involved.
|
|
16
|
+
- Added a deterministic regression test for delayed prompt rejection and verified the error remains observable in plugin logs.
|
|
17
|
+
|
|
3
18
|
## 0.5.22
|
|
4
19
|
|
|
5
20
|
- Hardened compatibility with current OpenCode 1.18.15 while preserving the existing `>=1.4.0` peer range.
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode
|
|
|
16
16
|
|
|
17
17
|
## Current status
|
|
18
18
|
|
|
19
|
-
**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.
|
|
19
|
+
**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.
|
|
20
20
|
|
|
21
21
|
The known update-related symptoms from older builds are fixed:
|
|
22
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.24",
|
|
4
4
|
"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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,10 +18,13 @@ async function createHarness(options = {}) {
|
|
|
18
18
|
logs: [],
|
|
19
19
|
prompts: [],
|
|
20
20
|
shells: [],
|
|
21
|
+
summaries: [],
|
|
22
|
+
messageReads: [],
|
|
21
23
|
toasts: [],
|
|
22
24
|
tuiCommands: [],
|
|
23
25
|
}
|
|
24
26
|
const statuses = new Map([[sessionID, "idle"]])
|
|
27
|
+
const messageHistory = Array.isArray(options.messages) ? structuredClone(options.messages) : []
|
|
25
28
|
|
|
26
29
|
const client = {
|
|
27
30
|
app: {
|
|
@@ -35,7 +38,7 @@ async function createHarness(options = {}) {
|
|
|
35
38
|
executeCommand: async (args) => {
|
|
36
39
|
assert.ok(args?.body?.command, "tui.executeCommand must use the SDK body shape")
|
|
37
40
|
records.tuiCommands.push(args.body.command)
|
|
38
|
-
if (options.failCompact) throw new Error("simulated TUI compact failure")
|
|
41
|
+
if (options.failCompact || options.failTuiCompact) throw new Error("simulated TUI compact failure")
|
|
39
42
|
return { data: true }
|
|
40
43
|
},
|
|
41
44
|
showToast: async (args) => {
|
|
@@ -75,6 +78,10 @@ async function createHarness(options = {}) {
|
|
|
75
78
|
assert.equal(args?.path?.id, sessionID)
|
|
76
79
|
const text = args.body.parts.map((part) => part.text || "").join("\n")
|
|
77
80
|
records.prompts.push({ text, noReply: args.body.noReply === true, agent: args.body.agent, model: args.body.model })
|
|
81
|
+
if (options.failPrompt) {
|
|
82
|
+
await delay(options.promptFailureDelayMs ?? 20)
|
|
83
|
+
throw new Error(options.promptFailureMessage || "simulated prompt dispatch failure")
|
|
84
|
+
}
|
|
78
85
|
return { data: true }
|
|
79
86
|
},
|
|
80
87
|
shell: async (args) => {
|
|
@@ -91,7 +98,15 @@ async function createHarness(options = {}) {
|
|
|
91
98
|
),
|
|
92
99
|
}
|
|
93
100
|
},
|
|
94
|
-
|
|
101
|
+
messages: async (args) => {
|
|
102
|
+
assert.equal(args?.path?.id, sessionID)
|
|
103
|
+
assert.equal(args?.query?.directory, directory)
|
|
104
|
+
records.messageReads.push(args)
|
|
105
|
+
return { data: structuredClone(messageHistory) }
|
|
106
|
+
},
|
|
107
|
+
summarize: async (args) => {
|
|
108
|
+
assert.equal(args?.path?.id, sessionID)
|
|
109
|
+
records.summaries.push(args?.body)
|
|
95
110
|
if (options.failCompact) throw new Error("simulated summarize failure")
|
|
96
111
|
return { data: true }
|
|
97
112
|
},
|
|
@@ -108,6 +123,7 @@ async function createHarness(options = {}) {
|
|
|
108
123
|
sessionID,
|
|
109
124
|
stateFile,
|
|
110
125
|
statuses,
|
|
126
|
+
messageHistory,
|
|
111
127
|
async command(command, argumentsText = "", output = { parts: [] }) {
|
|
112
128
|
await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
|
|
113
129
|
return output
|
|
@@ -374,6 +390,136 @@ async function testActionRoutingAndSafety() {
|
|
|
374
390
|
}
|
|
375
391
|
}
|
|
376
392
|
|
|
393
|
+
async function testNativeCompactionLifecycleAndFallback() {
|
|
394
|
+
let h = await createHarness({ failTuiCompact: true })
|
|
395
|
+
try {
|
|
396
|
+
await h.command("loop-compact", "0s --no-now")
|
|
397
|
+
await h.command("loop-now", "compact")
|
|
398
|
+
assert.deepEqual(h.records.summaries[0], {
|
|
399
|
+
providerID: "test-provider",
|
|
400
|
+
modelID: "test-model",
|
|
401
|
+
auto: false,
|
|
402
|
+
}, "headless compact fallback must satisfy the current OpenCode summarize payload")
|
|
403
|
+
assert.equal(typeof h.hooks["experimental.session.compacting"], "function")
|
|
404
|
+
const compactOutput = { context: [], prompt: undefined }
|
|
405
|
+
await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput)
|
|
406
|
+
assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt")
|
|
407
|
+
await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
|
|
408
|
+
await delay(20)
|
|
409
|
+
const state = await h.readState()
|
|
410
|
+
assert.ok(state.jobs[0].lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
|
|
411
|
+
} finally {
|
|
412
|
+
await h.cleanup()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
h = await createHarness()
|
|
416
|
+
try {
|
|
417
|
+
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")
|
|
418
|
+
const seeded = await h.readState()
|
|
419
|
+
seeded.jobs[0].runCount = 1
|
|
420
|
+
seeded.jobs[0].lastRunAt = 0
|
|
421
|
+
await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
|
|
422
|
+
|
|
423
|
+
await h.command("loop-now", "compact-chain")
|
|
424
|
+
assert.equal(h.records.tuiCommands.length, 1, "compact-every must start compaction")
|
|
425
|
+
assert.equal(h.actionTexts().length, 0, "the scheduled action must not overlap a pending compaction")
|
|
426
|
+
assert.equal((await h.readState()).jobs[0].runCount, 1, "compaction-only phase must not count as a normal loop run")
|
|
427
|
+
|
|
428
|
+
await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, { context: [], prompt: undefined })
|
|
429
|
+
await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
|
|
430
|
+
await delay(20)
|
|
431
|
+
assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action")
|
|
432
|
+
} finally {
|
|
433
|
+
await h.cleanup()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
h = await createHarness()
|
|
437
|
+
try {
|
|
438
|
+
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")
|
|
439
|
+
const seeded = await h.readState()
|
|
440
|
+
seeded.jobs[0].runCount = 1
|
|
441
|
+
seeded.jobs[0].lastRunAt = 0
|
|
442
|
+
await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
|
|
443
|
+
await h.command("loop-now", "compact-idle-fallback")
|
|
444
|
+
assert.equal(h.actionTexts().length, 0)
|
|
445
|
+
h.statuses.set(h.sessionID, "idle")
|
|
446
|
+
await h.command("loop-now", "compact-idle-fallback")
|
|
447
|
+
const fallbackState = await h.readState()
|
|
448
|
+
assert.equal(fallbackState.jobs[0].failureCount || 0, 0, "idle-only compaction fallback must not run normal verify logic")
|
|
449
|
+
assert.equal(fallbackState.jobs[0].paused, false, "idle-only compaction fallback must not pause the job through normal run finalization")
|
|
450
|
+
} finally {
|
|
451
|
+
await h.cleanup()
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function testStaleBusyUsesCompletedAssistantTail() {
|
|
456
|
+
let h = await createHarness()
|
|
457
|
+
try {
|
|
458
|
+
await h.command("loop", "5m --no-now --name stale-complete continue safely")
|
|
459
|
+
await h.command("loop-now", "stale-complete")
|
|
460
|
+
h.statuses.set(h.sessionID, "busy")
|
|
461
|
+
const completedAt = Date.now() + 5
|
|
462
|
+
h.messageHistory.splice(0, h.messageHistory.length,
|
|
463
|
+
{ info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },
|
|
464
|
+
{ info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },
|
|
465
|
+
)
|
|
466
|
+
await h.command("loop-now", "stale-complete")
|
|
467
|
+
const state = await h.readState()
|
|
468
|
+
assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
|
|
469
|
+
assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history")
|
|
470
|
+
} finally {
|
|
471
|
+
await h.cleanup()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
h = await createHarness()
|
|
475
|
+
try {
|
|
476
|
+
await h.command("loop", "5m --no-now --name stale-incomplete continue safely")
|
|
477
|
+
const seeded = await h.readState()
|
|
478
|
+
seeded.jobs[0].staleActiveRecoveryMs = 1
|
|
479
|
+
await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
|
|
480
|
+
await h.command("loop-now", "stale-incomplete")
|
|
481
|
+
await delay(10)
|
|
482
|
+
h.statuses.set(h.sessionID, "busy")
|
|
483
|
+
const createdAt = Date.now()
|
|
484
|
+
h.messageHistory.splice(0, h.messageHistory.length,
|
|
485
|
+
{ info: { id: "usr_running", sessionID: h.sessionID, role: "user", time: { created: createdAt - 1 } }, parts: [] },
|
|
486
|
+
{ info: { id: "asst_running", sessionID: h.sessionID, role: "assistant", time: { created: createdAt } }, parts: [] },
|
|
487
|
+
)
|
|
488
|
+
await h.command("loop-now", "stale-incomplete")
|
|
489
|
+
const state = await h.readState()
|
|
490
|
+
assert.equal(state.jobs[0].lastFinishedAt, undefined, "an unfinished assistant tail must never be force-finalized just because the active-run timeout elapsed")
|
|
491
|
+
assert.equal(h.actionTexts().length, 1, "unfinished work must not be overlapped by a replacement prompt")
|
|
492
|
+
} finally {
|
|
493
|
+
await h.cleanup()
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function testPromptDispatchFailureRecovery() {
|
|
498
|
+
const h = await createHarness({ failPrompt: true, promptFailureDelayMs: 25 })
|
|
499
|
+
try {
|
|
500
|
+
await h.command("loop", "0s --no-now --name dispatch-failure --max-failures 1 recover from a rejected prompt dispatch")
|
|
501
|
+
await h.command("loop-now", "dispatch-failure")
|
|
502
|
+
|
|
503
|
+
let state
|
|
504
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
505
|
+
state = await h.readState()
|
|
506
|
+
if (state.jobs[0]?.lastFailureReason === "dispatch_failed") break
|
|
507
|
+
await delay(25)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const job = state.jobs[0]
|
|
511
|
+
assert.equal(job.failureCount, 1, "a rejected prompt dispatch must count as a scheduler failure")
|
|
512
|
+
assert.equal(job.paused, true, "--max-failures must pause after a rejected prompt dispatch")
|
|
513
|
+
assert.equal(job.lastFailureReason, "dispatch_failed")
|
|
514
|
+
assert.match(job.lastDispatchFailure, /simulated prompt dispatch failure/)
|
|
515
|
+
assert.ok(job.lastDispatchFailureAt > 0)
|
|
516
|
+
assert.equal(h.actionTexts().length, 1, "dispatch recovery must never replay the prompt automatically")
|
|
517
|
+
assert.ok(h.records.logs.some((entry) => entry.message === "session.prompt failed"), "the SDK rejection must remain observable")
|
|
518
|
+
} finally {
|
|
519
|
+
await h.cleanup()
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
377
523
|
async function testStopsPreflightAndGoalLifecycle() {
|
|
378
524
|
let h = await createHarness()
|
|
379
525
|
try {
|
|
@@ -563,6 +709,9 @@ await testParserAndPresets()
|
|
|
563
709
|
await testLifecycleAndCommandDedupe()
|
|
564
710
|
await testWatchScheduling()
|
|
565
711
|
await testActionRoutingAndSafety()
|
|
712
|
+
await testNativeCompactionLifecycleAndFallback()
|
|
713
|
+
await testStaleBusyUsesCompletedAssistantTail()
|
|
714
|
+
await testPromptDispatchFailureRecovery()
|
|
566
715
|
await testStopsPreflightAndGoalLifecycle()
|
|
567
716
|
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
|
|
568
717
|
await testInitializationDoesNotWaitForLocalApi()
|
package/src/index.js
CHANGED
|
@@ -40,6 +40,7 @@ const sessionParents = new Map()
|
|
|
40
40
|
let heartbeatTimer
|
|
41
41
|
const sessionStatuses = new Map()
|
|
42
42
|
const sessionStatusSeenAt = new Map()
|
|
43
|
+
const loopCompactionRequests = new Map()
|
|
43
44
|
|
|
44
45
|
const DEFAULT_PROGRESS_MD = `# Progress
|
|
45
46
|
|
|
@@ -510,9 +511,11 @@ async function sdkCall(method, ...argsList) {
|
|
|
510
511
|
}
|
|
511
512
|
|
|
512
513
|
function fireSdk(client, label, method, ...argsList) {
|
|
513
|
-
Promise.resolve()
|
|
514
|
-
|
|
515
|
-
|
|
514
|
+
const pending = Promise.resolve().then(() => sdkCall(method, ...argsList))
|
|
515
|
+
void pending.catch((error) => {
|
|
516
|
+
log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }).catch(() => {})
|
|
517
|
+
})
|
|
518
|
+
return pending
|
|
516
519
|
}
|
|
517
520
|
|
|
518
521
|
async function executeTuiCommand(client, command) {
|
|
@@ -530,11 +533,72 @@ function compactTuiCommandName(command = "compact") {
|
|
|
530
533
|
return undefined
|
|
531
534
|
}
|
|
532
535
|
|
|
533
|
-
async function
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
536
|
+
async function readRecentSessionMessages(client, sessionID, directory, limit = 20) {
|
|
537
|
+
if (!client?.session?.messages) return undefined
|
|
538
|
+
const query = { limit }
|
|
539
|
+
if (directory) query.directory = directory
|
|
540
|
+
try {
|
|
541
|
+
const messages = await sdkCall(
|
|
542
|
+
client.session.messages.bind(client.session),
|
|
543
|
+
{ path: { id: sessionID }, query },
|
|
544
|
+
{ path: { sessionID }, query },
|
|
545
|
+
{ sessionID, ...query },
|
|
546
|
+
)
|
|
547
|
+
return Array.isArray(messages) ? messages : undefined
|
|
548
|
+
} catch {
|
|
549
|
+
return undefined
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function orderedSessionMessages(messages) {
|
|
554
|
+
return (messages || [])
|
|
555
|
+
.map((message, index) => {
|
|
556
|
+
const info = message?.info || message || {}
|
|
557
|
+
const created = Number(info?.time?.created || 0)
|
|
558
|
+
return { message, index, created: Number.isFinite(created) ? created : 0 }
|
|
559
|
+
})
|
|
560
|
+
.sort((a, b) => a.created - b.created || a.index - b.index)
|
|
561
|
+
.map((entry) => entry.message)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
|
|
565
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
566
|
+
if (!messages) return "unknown"
|
|
567
|
+
const tail = orderedSessionMessages(messages).at(-1)
|
|
568
|
+
const info = tail?.info || tail
|
|
569
|
+
if (!info || info.role !== "assistant") return "incomplete"
|
|
570
|
+
const completed = Number(info?.time?.completed || 0)
|
|
571
|
+
const created = Number(info?.time?.created || 0)
|
|
572
|
+
if (!Number.isFinite(completed) || completed <= 0) return "incomplete"
|
|
573
|
+
const startedAt = Number(active?.startedAt || 0)
|
|
574
|
+
if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"
|
|
575
|
+
return "completed"
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
|
|
579
|
+
const preferred = normalizedModelRef(preferredModel)
|
|
580
|
+
if (preferred) return preferred
|
|
581
|
+
const cached = normalizedModelRef(sessionExecutionContexts.get(sessionID)?.model)
|
|
582
|
+
if (cached) return cached
|
|
583
|
+
const captured = await captureSessionExecutionContext(client, sessionID)
|
|
584
|
+
const capturedModel = normalizedModelRef(captured?.model)
|
|
585
|
+
if (capturedModel) return capturedModel
|
|
586
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
587
|
+
for (const message of orderedSessionMessages(messages).reverse()) {
|
|
588
|
+
const info = message?.info || message
|
|
589
|
+
const model = normalizedModelRef(info?.model) || normalizedModelRef(info)
|
|
590
|
+
if (!model) continue
|
|
591
|
+
const previous = sessionExecutionContexts.get(sessionID) || {}
|
|
592
|
+
sessionExecutionContexts.set(sessionID, { ...previous, model })
|
|
593
|
+
return model
|
|
594
|
+
}
|
|
595
|
+
return undefined
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function compactSession(directory, client, sessionID, preferredModel) {
|
|
599
|
+
// Prefer the native TUI command when a TUI is present. Headless/server hosts
|
|
600
|
+
// fall back to session.summarize, whose current API requires an explicit
|
|
601
|
+
// provider/model pair.
|
|
538
602
|
for (const command of ["session.compact", "session_compact"]) {
|
|
539
603
|
try {
|
|
540
604
|
await executeTuiCommand(client, command)
|
|
@@ -544,17 +608,21 @@ async function compactSession(client, sessionID) {
|
|
|
544
608
|
}
|
|
545
609
|
}
|
|
546
610
|
try {
|
|
611
|
+
if (!client?.session?.summarize) throw new Error("client.session.summarize is not available")
|
|
612
|
+
const model = await resolveCompactionModel(directory, client, sessionID, preferredModel)
|
|
613
|
+
if (!model) throw new Error("could not resolve a provider/model for session.summarize")
|
|
614
|
+
const body = { providerID: model.providerID, modelID: model.modelID, auto: false }
|
|
547
615
|
await sdkCall(
|
|
548
616
|
client.session.summarize.bind(client.session),
|
|
549
|
-
{ path: { id: sessionID }, body
|
|
550
|
-
{ path: { sessionID }, body
|
|
551
|
-
{ sessionID },
|
|
617
|
+
{ path: { id: sessionID }, body },
|
|
618
|
+
{ path: { sessionID }, body },
|
|
619
|
+
{ sessionID, ...body },
|
|
552
620
|
)
|
|
553
621
|
return true
|
|
554
622
|
} catch (error) {
|
|
555
623
|
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
556
624
|
}
|
|
557
|
-
await toast(client, "Could not run /compact from loop. Check OpenCode version and active
|
|
625
|
+
await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error")
|
|
558
626
|
return false
|
|
559
627
|
}
|
|
560
628
|
|
|
@@ -848,6 +916,7 @@ function disposeRuntime(directory, client) {
|
|
|
848
916
|
sessionStatuses.delete(sessionID)
|
|
849
917
|
sessionStatusSeenAt.delete(sessionID)
|
|
850
918
|
sessionExecutionContexts.delete(sessionID)
|
|
919
|
+
loopCompactionRequests.delete(sessionID)
|
|
851
920
|
for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
|
|
852
921
|
for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
|
|
853
922
|
}
|
|
@@ -1077,15 +1146,18 @@ async function ensureBranch(directory, job, client, sessionID) {
|
|
|
1077
1146
|
return job
|
|
1078
1147
|
}
|
|
1079
1148
|
|
|
1080
|
-
async function maybeCompact(client, sessionID, job) {
|
|
1149
|
+
async function maybeCompact(directory, client, sessionID, job) {
|
|
1081
1150
|
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
1082
1151
|
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
1083
|
-
if (!dueRuns && !dueTime) return job
|
|
1084
|
-
|
|
1152
|
+
if (!dueRuns && !dueTime) return { job, started: false }
|
|
1153
|
+
beginLoopCompaction(sessionID, job.id, true)
|
|
1154
|
+
if (await compactSession(directory, client, sessionID, job.model)) {
|
|
1085
1155
|
job.lastCompactAt = now()
|
|
1086
1156
|
job.lastCompactRunCount = job.runCount || 0
|
|
1157
|
+
return { job, started: true }
|
|
1087
1158
|
}
|
|
1088
|
-
|
|
1159
|
+
loopCompactionRequests.delete(sessionID)
|
|
1160
|
+
return { job, started: false }
|
|
1089
1161
|
}
|
|
1090
1162
|
|
|
1091
1163
|
async function snapshotPaths(directory, files) {
|
|
@@ -1224,12 +1296,21 @@ function staleActiveRun(sessionID) {
|
|
|
1224
1296
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
1225
1297
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
|
|
1226
1298
|
if (!options.requireIdle && !options.forceStale) return true
|
|
1227
|
-
|
|
1228
|
-
|
|
1299
|
+
|
|
1300
|
+
const completion = options.forceStale
|
|
1301
|
+
? await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
1302
|
+
: undefined
|
|
1303
|
+
if (completion === "completed") return true
|
|
1304
|
+
if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
|
|
1229
1305
|
|
|
1230
1306
|
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
1231
|
-
if (live?.type
|
|
1307
|
+
if (live?.type === "idle") return true
|
|
1308
|
+
if (live?.type) {
|
|
1309
|
+
if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
1310
|
+
return false
|
|
1311
|
+
}
|
|
1232
1312
|
|
|
1313
|
+
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
1233
1314
|
const cached = sessionStatuses.get(sessionID)
|
|
1234
1315
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
1235
1316
|
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
@@ -1297,10 +1378,21 @@ async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
|
1297
1378
|
// plugin-injected turn until the next user command touches the session.
|
|
1298
1379
|
// When the only reason we still think the session is busy is our own stale
|
|
1299
1380
|
// active-run guard, recover instead of waiting for another manual command.
|
|
1300
|
-
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1381
|
+
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
1382
|
+
const active = activeRuns.get(sessionID)
|
|
1383
|
+
if (active) {
|
|
1384
|
+
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
1385
|
+
if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
|
|
1386
|
+
sessionStatuses.set(sessionID, "idle")
|
|
1387
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1388
|
+
await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
|
|
1389
|
+
sessionID,
|
|
1390
|
+
job: active.job?.name || active.jobId,
|
|
1391
|
+
startedAt: active.startedAt,
|
|
1392
|
+
})
|
|
1393
|
+
return "idle"
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1304
1396
|
}
|
|
1305
1397
|
sessionStatuses.set(sessionID, live.type)
|
|
1306
1398
|
sessionStatusSeenAt.set(sessionID, now())
|
|
@@ -1446,9 +1538,83 @@ function dueJobs(state, force = false) {
|
|
|
1446
1538
|
function clearActiveRun(sessionID) {
|
|
1447
1539
|
const active = activeRuns.get(sessionID)
|
|
1448
1540
|
if (active?.timer) clearTimeout(active.timer)
|
|
1541
|
+
const compact = loopCompactionRequests.get(sessionID)
|
|
1542
|
+
if (!compact || !active || compact.jobId === active.jobId) loopCompactionRequests.delete(sessionID)
|
|
1449
1543
|
activeRuns.delete(sessionID)
|
|
1450
1544
|
}
|
|
1451
1545
|
|
|
1546
|
+
function beginLoopCompaction(sessionID, jobId, resumeAfter = false) {
|
|
1547
|
+
loopCompactionRequests.set(sessionID, {
|
|
1548
|
+
jobId,
|
|
1549
|
+
resumeAfter,
|
|
1550
|
+
requestedAt: now(),
|
|
1551
|
+
startedAt: 0,
|
|
1552
|
+
completedAt: 0,
|
|
1553
|
+
})
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async function noteLoopCompactionStarted(directory, sessionID) {
|
|
1557
|
+
const pending = loopCompactionRequests.get(sessionID)
|
|
1558
|
+
if (!pending) return false
|
|
1559
|
+
if (!pending.startedAt) {
|
|
1560
|
+
pending.startedAt = now()
|
|
1561
|
+
loopCompactionRequests.set(sessionID, pending)
|
|
1562
|
+
await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter })
|
|
1563
|
+
}
|
|
1564
|
+
return true
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async function finalizeLoopCompaction(directory, client, sessionID) {
|
|
1568
|
+
const pending = loopCompactionRequests.get(sessionID)
|
|
1569
|
+
const active = activeRuns.get(sessionID)
|
|
1570
|
+
if (!pending || !active || pending.jobId !== active.jobId) return false
|
|
1571
|
+
return await finalizeActiveRun(directory, client, sessionID)
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
async function noteLoopCompactionCompleted(directory, client, sessionID) {
|
|
1575
|
+
const pending = loopCompactionRequests.get(sessionID)
|
|
1576
|
+
if (!pending) return false
|
|
1577
|
+
pending.completedAt = now()
|
|
1578
|
+
loopCompactionRequests.set(sessionID, pending)
|
|
1579
|
+
await appendLoopLog(directory, "compact-event", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter })
|
|
1580
|
+
const timer = setTimeout(() => {
|
|
1581
|
+
finalizeLoopCompaction(directory, client, sessionID)
|
|
1582
|
+
.catch((error) => log(client, "error", "compaction finalization failed", { error: sdkErrorMessage(error) }))
|
|
1583
|
+
}, 0)
|
|
1584
|
+
timer.unref?.()
|
|
1585
|
+
return true
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
1589
|
+
const active = activeRuns.get(sessionID)
|
|
1590
|
+
if (!active || active.jobId !== jobId || active.runToken !== runToken) return false
|
|
1591
|
+
|
|
1592
|
+
clearActiveRun(sessionID)
|
|
1593
|
+
sessionStatuses.delete(sessionID)
|
|
1594
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
1595
|
+
|
|
1596
|
+
const message = sdkErrorMessage(error)
|
|
1597
|
+
const state = await readState(directory, sessionID)
|
|
1598
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === jobId)
|
|
1599
|
+
if (job) {
|
|
1600
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1601
|
+
job.lastFailureReason = "dispatch_failed"
|
|
1602
|
+
job.lastDispatchFailure = message.slice(0, 4000)
|
|
1603
|
+
job.lastDispatchFailureAt = now()
|
|
1604
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1605
|
+
job.paused = true
|
|
1606
|
+
await notifyJob(directory, job, "dispatch_failed")
|
|
1607
|
+
}
|
|
1608
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1609
|
+
await writeState(directory, sessionID, state)
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
await appendLoopLog(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message })
|
|
1613
|
+
await toast(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning")
|
|
1614
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1615
|
+
return true
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1452
1618
|
function goalReportPath(directory, sessionID, job) {
|
|
1453
1619
|
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
1454
1620
|
}
|
|
@@ -1686,6 +1852,20 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
|
1686
1852
|
if (!active) return
|
|
1687
1853
|
if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
|
|
1688
1854
|
const recoveredStale = staleActiveRun(sessionID)
|
|
1855
|
+
if (active.compactionOnly) {
|
|
1856
|
+
const pending = loopCompactionRequests.get(sessionID)
|
|
1857
|
+
clearActiveRun(sessionID)
|
|
1858
|
+
sessionStatuses.delete(sessionID)
|
|
1859
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
1860
|
+
await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
|
|
1861
|
+
sessionID,
|
|
1862
|
+
job: active.job?.name || active.jobId,
|
|
1863
|
+
startedAt: active.startedAt,
|
|
1864
|
+
nativeEvent: Boolean(pending?.completedAt),
|
|
1865
|
+
})
|
|
1866
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1867
|
+
return true
|
|
1868
|
+
}
|
|
1689
1869
|
clearActiveRun(sessionID)
|
|
1690
1870
|
const state = await readState(directory, sessionID)
|
|
1691
1871
|
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
@@ -1749,8 +1929,10 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1749
1929
|
const agent = job.agent || "build"
|
|
1750
1930
|
const model = normalizedModelRef(job.model)
|
|
1751
1931
|
if (kind === "compact") {
|
|
1752
|
-
|
|
1753
|
-
|
|
1932
|
+
beginLoopCompaction(sessionID, job.id, false)
|
|
1933
|
+
const ok = await compactSession(directory, client, sessionID, model)
|
|
1934
|
+
if (!ok) loopCompactionRequests.delete(sessionID)
|
|
1935
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
|
|
1754
1936
|
}
|
|
1755
1937
|
if (kind === "command") {
|
|
1756
1938
|
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
@@ -1762,8 +1944,10 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1762
1944
|
const tuiCommand = compactTuiCommandName(command)
|
|
1763
1945
|
if (tuiCommand) {
|
|
1764
1946
|
guardLoopOwnedUserMessage(sessionID)
|
|
1765
|
-
|
|
1766
|
-
|
|
1947
|
+
beginLoopCompaction(sessionID, job.id, false)
|
|
1948
|
+
const ok = await compactSession(directory, client, sessionID, model)
|
|
1949
|
+
if (!ok) loopCompactionRequests.delete(sessionID)
|
|
1950
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
|
|
1767
1951
|
}
|
|
1768
1952
|
guardLoopOwnedUserMessage(sessionID)
|
|
1769
1953
|
const commandBody = { command, arguments: argumentsText, agent }
|
|
@@ -1786,7 +1970,7 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1786
1970
|
guardLoopOwnedUserMessage(sessionID)
|
|
1787
1971
|
const shellBody = { command, agent }
|
|
1788
1972
|
if (model) shellBody.model = model
|
|
1789
|
-
fireSdk(
|
|
1973
|
+
const dispatch = fireSdk(
|
|
1790
1974
|
client,
|
|
1791
1975
|
"session.shell",
|
|
1792
1976
|
client.session.shell.bind(client.session),
|
|
@@ -1794,7 +1978,7 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1794
1978
|
{ path: { sessionID }, body: shellBody },
|
|
1795
1979
|
{ sessionID, ...shellBody },
|
|
1796
1980
|
)
|
|
1797
|
-
return { startsAssistantTurn: true }
|
|
1981
|
+
return { startsAssistantTurn: true, dispatch }
|
|
1798
1982
|
}
|
|
1799
1983
|
const prompt = await buildPrompt(directory, job)
|
|
1800
1984
|
const prefix = kind === "goal"
|
|
@@ -1806,7 +1990,7 @@ ${prompt}`
|
|
|
1806
1990
|
guardLoopOwnedUserMessage(sessionID)
|
|
1807
1991
|
const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
|
|
1808
1992
|
if (model) promptBody.model = model
|
|
1809
|
-
fireSdk(
|
|
1993
|
+
const dispatch = fireSdk(
|
|
1810
1994
|
client,
|
|
1811
1995
|
"session.prompt",
|
|
1812
1996
|
client.session.prompt.bind(client.session),
|
|
@@ -1814,7 +1998,7 @@ ${prompt}`
|
|
|
1814
1998
|
{ path: { sessionID }, body: promptBody },
|
|
1815
1999
|
{ sessionID, ...promptBody },
|
|
1816
2000
|
)
|
|
1817
|
-
return { startsAssistantTurn: true }
|
|
2001
|
+
return { startsAssistantTurn: true, dispatch }
|
|
1818
2002
|
}
|
|
1819
2003
|
|
|
1820
2004
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
@@ -1907,7 +2091,25 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1907
2091
|
}
|
|
1908
2092
|
|
|
1909
2093
|
job = await ensureBranch(directory, job, client, sessionID)
|
|
1910
|
-
|
|
2094
|
+
const compactResult = await maybeCompact(directory, client, sessionID, job)
|
|
2095
|
+
job = compactResult.job
|
|
2096
|
+
if (compactResult.started) {
|
|
2097
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
2098
|
+
await writeState(directory, sessionID, state)
|
|
2099
|
+
let timer
|
|
2100
|
+
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)
|
|
2101
|
+
const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
|
|
2102
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true })
|
|
2103
|
+
const pending = loopCompactionRequests.get(sessionID)
|
|
2104
|
+
if (pending?.jobId === job.id && pending.completedAt) {
|
|
2105
|
+
await finalizeLoopCompaction(directory, client, sessionID)
|
|
2106
|
+
return
|
|
2107
|
+
}
|
|
2108
|
+
sessionStatuses.set(sessionID, "busy")
|
|
2109
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
2110
|
+
await reschedule(BUSY_RETRY_MS)
|
|
2111
|
+
return
|
|
2112
|
+
}
|
|
1911
2113
|
job.watchTriggered = false
|
|
1912
2114
|
job.lastRunAt = now()
|
|
1913
2115
|
job.runCount = (job.runCount || 0) + 1
|
|
@@ -1939,7 +2141,21 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1939
2141
|
}
|
|
1940
2142
|
let timer
|
|
1941
2143
|
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)
|
|
1942
|
-
|
|
2144
|
+
const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
|
|
2145
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionAction: result.compaction === true })
|
|
2146
|
+
if (result.compaction) {
|
|
2147
|
+
const pending = loopCompactionRequests.get(sessionID)
|
|
2148
|
+
if (pending?.jobId === job.id && pending.completedAt) {
|
|
2149
|
+
await finalizeLoopCompaction(directory, client, sessionID)
|
|
2150
|
+
return
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
if (result.dispatch) {
|
|
2154
|
+
void result.dispatch.catch((error) => {
|
|
2155
|
+
recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error)
|
|
2156
|
+
.catch((recoveryError) => log(client, "error", "dispatch recovery failed", { error: sdkErrorMessage(recoveryError) }))
|
|
2157
|
+
})
|
|
2158
|
+
}
|
|
1943
2159
|
sessionStatuses.set(sessionID, "busy")
|
|
1944
2160
|
sessionStatusSeenAt.set(sessionID, now())
|
|
1945
2161
|
await reschedule(BUSY_RETRY_MS)
|
|
@@ -2305,7 +2521,9 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
2305
2521
|
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
2306
2522
|
"tool.execute.before": async (input) => { markToolCallActive(input) },
|
|
2307
2523
|
"tool.execute.after": async (input) => { markToolCallFinished(input) },
|
|
2524
|
+
"experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
|
|
2308
2525
|
event: async ({ event }) => {
|
|
2526
|
+
if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
|
|
2309
2527
|
updateSessionRelationshipFromEvent(event)
|
|
2310
2528
|
if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
|
|
2311
2529
|
updateToolActivityFromEvent(event)
|