@bybrawe/opencode-loop 0.5.23 → 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 +8 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/comprehensive-test.mjs +120 -2
- package/src/index.js +205 -26
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.5.23
|
|
4
12
|
|
|
5
13
|
- Track non-blocking `session.prompt` and `session.shell` dispatch promises instead of treating every fire-and-forget request as successfully started.
|
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.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.
|
|
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) => {
|
|
@@ -95,7 +98,15 @@ async function createHarness(options = {}) {
|
|
|
95
98
|
),
|
|
96
99
|
}
|
|
97
100
|
},
|
|
98
|
-
|
|
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)
|
|
99
110
|
if (options.failCompact) throw new Error("simulated summarize failure")
|
|
100
111
|
return { data: true }
|
|
101
112
|
},
|
|
@@ -112,6 +123,7 @@ async function createHarness(options = {}) {
|
|
|
112
123
|
sessionID,
|
|
113
124
|
stateFile,
|
|
114
125
|
statuses,
|
|
126
|
+
messageHistory,
|
|
115
127
|
async command(command, argumentsText = "", output = { parts: [] }) {
|
|
116
128
|
await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
|
|
117
129
|
return output
|
|
@@ -378,6 +390,110 @@ async function testActionRoutingAndSafety() {
|
|
|
378
390
|
}
|
|
379
391
|
}
|
|
380
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
|
+
|
|
381
497
|
async function testPromptDispatchFailureRecovery() {
|
|
382
498
|
const h = await createHarness({ failPrompt: true, promptFailureDelayMs: 25 })
|
|
383
499
|
try {
|
|
@@ -593,6 +709,8 @@ await testParserAndPresets()
|
|
|
593
709
|
await testLifecycleAndCommandDedupe()
|
|
594
710
|
await testWatchScheduling()
|
|
595
711
|
await testActionRoutingAndSafety()
|
|
712
|
+
await testNativeCompactionLifecycleAndFallback()
|
|
713
|
+
await testStaleBusyUsesCompletedAssistantTail()
|
|
596
714
|
await testPromptDispatchFailureRecovery()
|
|
597
715
|
await testStopsPreflightAndGoalLifecycle()
|
|
598
716
|
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
|
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
|
|
|
@@ -532,11 +533,72 @@ function compactTuiCommandName(command = "compact") {
|
|
|
532
533
|
return undefined
|
|
533
534
|
}
|
|
534
535
|
|
|
535
|
-
async function
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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.
|
|
540
602
|
for (const command of ["session.compact", "session_compact"]) {
|
|
541
603
|
try {
|
|
542
604
|
await executeTuiCommand(client, command)
|
|
@@ -546,17 +608,21 @@ async function compactSession(client, sessionID) {
|
|
|
546
608
|
}
|
|
547
609
|
}
|
|
548
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 }
|
|
549
615
|
await sdkCall(
|
|
550
616
|
client.session.summarize.bind(client.session),
|
|
551
|
-
{ path: { id: sessionID }, body
|
|
552
|
-
{ path: { sessionID }, body
|
|
553
|
-
{ sessionID },
|
|
617
|
+
{ path: { id: sessionID }, body },
|
|
618
|
+
{ path: { sessionID }, body },
|
|
619
|
+
{ sessionID, ...body },
|
|
554
620
|
)
|
|
555
621
|
return true
|
|
556
622
|
} catch (error) {
|
|
557
623
|
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
558
624
|
}
|
|
559
|
-
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")
|
|
560
626
|
return false
|
|
561
627
|
}
|
|
562
628
|
|
|
@@ -850,6 +916,7 @@ function disposeRuntime(directory, client) {
|
|
|
850
916
|
sessionStatuses.delete(sessionID)
|
|
851
917
|
sessionStatusSeenAt.delete(sessionID)
|
|
852
918
|
sessionExecutionContexts.delete(sessionID)
|
|
919
|
+
loopCompactionRequests.delete(sessionID)
|
|
853
920
|
for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
|
|
854
921
|
for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
|
|
855
922
|
}
|
|
@@ -1079,15 +1146,18 @@ async function ensureBranch(directory, job, client, sessionID) {
|
|
|
1079
1146
|
return job
|
|
1080
1147
|
}
|
|
1081
1148
|
|
|
1082
|
-
async function maybeCompact(client, sessionID, job) {
|
|
1149
|
+
async function maybeCompact(directory, client, sessionID, job) {
|
|
1083
1150
|
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
1084
1151
|
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
1085
|
-
if (!dueRuns && !dueTime) return job
|
|
1086
|
-
|
|
1152
|
+
if (!dueRuns && !dueTime) return { job, started: false }
|
|
1153
|
+
beginLoopCompaction(sessionID, job.id, true)
|
|
1154
|
+
if (await compactSession(directory, client, sessionID, job.model)) {
|
|
1087
1155
|
job.lastCompactAt = now()
|
|
1088
1156
|
job.lastCompactRunCount = job.runCount || 0
|
|
1157
|
+
return { job, started: true }
|
|
1089
1158
|
}
|
|
1090
|
-
|
|
1159
|
+
loopCompactionRequests.delete(sessionID)
|
|
1160
|
+
return { job, started: false }
|
|
1091
1161
|
}
|
|
1092
1162
|
|
|
1093
1163
|
async function snapshotPaths(directory, files) {
|
|
@@ -1226,12 +1296,21 @@ function staleActiveRun(sessionID) {
|
|
|
1226
1296
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
1227
1297
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
|
|
1228
1298
|
if (!options.requireIdle && !options.forceStale) return true
|
|
1229
|
-
|
|
1230
|
-
|
|
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)
|
|
1231
1305
|
|
|
1232
1306
|
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
1233
|
-
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
|
+
}
|
|
1234
1312
|
|
|
1313
|
+
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
1235
1314
|
const cached = sessionStatuses.get(sessionID)
|
|
1236
1315
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
1237
1316
|
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
@@ -1299,10 +1378,21 @@ async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
|
1299
1378
|
// plugin-injected turn until the next user command touches the session.
|
|
1300
1379
|
// When the only reason we still think the session is busy is our own stale
|
|
1301
1380
|
// active-run guard, recover instead of waiting for another manual command.
|
|
1302
|
-
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
+
}
|
|
1306
1396
|
}
|
|
1307
1397
|
sessionStatuses.set(sessionID, live.type)
|
|
1308
1398
|
sessionStatusSeenAt.set(sessionID, now())
|
|
@@ -1448,9 +1538,53 @@ function dueJobs(state, force = false) {
|
|
|
1448
1538
|
function clearActiveRun(sessionID) {
|
|
1449
1539
|
const active = activeRuns.get(sessionID)
|
|
1450
1540
|
if (active?.timer) clearTimeout(active.timer)
|
|
1541
|
+
const compact = loopCompactionRequests.get(sessionID)
|
|
1542
|
+
if (!compact || !active || compact.jobId === active.jobId) loopCompactionRequests.delete(sessionID)
|
|
1451
1543
|
activeRuns.delete(sessionID)
|
|
1452
1544
|
}
|
|
1453
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
|
+
|
|
1454
1588
|
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
1455
1589
|
const active = activeRuns.get(sessionID)
|
|
1456
1590
|
if (!active || active.jobId !== jobId || active.runToken !== runToken) return false
|
|
@@ -1718,6 +1852,20 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
|
1718
1852
|
if (!active) return
|
|
1719
1853
|
if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
|
|
1720
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
|
+
}
|
|
1721
1869
|
clearActiveRun(sessionID)
|
|
1722
1870
|
const state = await readState(directory, sessionID)
|
|
1723
1871
|
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
@@ -1781,8 +1929,10 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1781
1929
|
const agent = job.agent || "build"
|
|
1782
1930
|
const model = normalizedModelRef(job.model)
|
|
1783
1931
|
if (kind === "compact") {
|
|
1784
|
-
|
|
1785
|
-
|
|
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 }
|
|
1786
1936
|
}
|
|
1787
1937
|
if (kind === "command") {
|
|
1788
1938
|
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
@@ -1794,8 +1944,10 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1794
1944
|
const tuiCommand = compactTuiCommandName(command)
|
|
1795
1945
|
if (tuiCommand) {
|
|
1796
1946
|
guardLoopOwnedUserMessage(sessionID)
|
|
1797
|
-
|
|
1798
|
-
|
|
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 }
|
|
1799
1951
|
}
|
|
1800
1952
|
guardLoopOwnedUserMessage(sessionID)
|
|
1801
1953
|
const commandBody = { command, arguments: argumentsText, agent }
|
|
@@ -1939,7 +2091,25 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1939
2091
|
}
|
|
1940
2092
|
|
|
1941
2093
|
job = await ensureBranch(directory, job, client, sessionID)
|
|
1942
|
-
|
|
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
|
+
}
|
|
1943
2113
|
job.watchTriggered = false
|
|
1944
2114
|
job.lastRunAt = now()
|
|
1945
2115
|
job.runCount = (job.runCount || 0) + 1
|
|
@@ -1972,7 +2142,14 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1972
2142
|
let timer
|
|
1973
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)
|
|
1974
2144
|
const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
|
|
1975
|
-
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken })
|
|
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
|
+
}
|
|
1976
2153
|
if (result.dispatch) {
|
|
1977
2154
|
void result.dispatch.catch((error) => {
|
|
1978
2155
|
recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error)
|
|
@@ -2344,7 +2521,9 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
2344
2521
|
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
2345
2522
|
"tool.execute.before": async (input) => { markToolCallActive(input) },
|
|
2346
2523
|
"tool.execute.after": async (input) => { markToolCallFinished(input) },
|
|
2524
|
+
"experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
|
|
2347
2525
|
event: async ({ event }) => {
|
|
2526
|
+
if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
|
|
2348
2527
|
updateSessionRelationshipFromEvent(event)
|
|
2349
2528
|
if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
|
|
2350
2529
|
updateToolActivityFromEvent(event)
|