@bybrawe/opencode-loop 0.5.35 → 0.5.36
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 +12 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/scripts/empty-turn-recovery-test.mjs +150 -0
- package/scripts/host-adapter-contract-test.mjs +2 -2
- package/scripts/host-goal-coexistence-canary.mjs +419 -0
- package/src/index.js +105 -6
- package/src/source/opencode/host.js +23 -2
- package/src/source/runtime/empty-turn.js +46 -0
- package/src/source/runtime/executor.js +28 -0
- package/src/source/runtime/session-status.js +14 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.36
|
|
4
|
+
|
|
5
|
+
Empty-turn fail-safe release.
|
|
6
|
+
|
|
7
|
+
- Distinguish a completed assistant message from a meaningful completed Loop turn instead of treating a timestamp-only blank reply as success.
|
|
8
|
+
- Count non-empty assistant text and tool/file/patch/artifact activity anywhere in the logical run as meaningful work, including tool-only runs with a blank final assistant tail.
|
|
9
|
+
- Refund a completed-but-empty logical run so it does not consume `runCount`, `--max-runs`, or normal success finalization/checkpoint behavior.
|
|
10
|
+
- Persist consecutive empty-turn state across reloads, retry one empty completion with bounded delay, then pause safely after the second consecutive empty turn with a visible warning instead of injecting prompts forever.
|
|
11
|
+
- Recover stale host `busy` state for already-completed empty turns so the fail-safe can finalize them rather than leaving the Loop stuck between busy and due states.
|
|
12
|
+
- Keep release metadata automation compatible with the README's `Current stable release` marker so future release prep does not fail on the documented format.
|
|
13
|
+
- Add focused regressions for blank/whitespace replies, tool activity, max-run refund/re-enable, bounded retry, fail-safe pause, successful streak reset, and generated-bundle parity; full Ubuntu/Windows CI, real-host, Goal coexistence, lifecycle, and single-file bundle gates are green on the feature head.
|
|
14
|
+
|
|
3
15
|
## 0.5.35
|
|
4
16
|
|
|
5
17
|
- Treat OpenCode `session.status=retry` as authoritative provider ownership: retry and unreadable status can no longer age into idle and cause an overlapping Loop turn; a genuinely completed current assistant message can still release stale retry safely.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
|
|
6
6
|
|
|
7
|
-
> **Current stable release: `0.5.
|
|
7
|
+
> **Current stable release: `0.5.36`.**
|
|
8
8
|
|
|
9
9
|
## Install or update
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.36",
|
|
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",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
|
|
22
22
|
"build:plugin:npm": "npm run build:plugin",
|
|
23
23
|
"prepack": "node --check src/index.js",
|
|
24
|
-
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
|
|
25
|
-
"test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
24
|
+
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
|
|
25
|
+
"test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
26
26
|
"canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
|
|
27
27
|
"install:global": "node scripts/install-with-goals.mjs",
|
|
28
28
|
"pack:zip": "node scripts/make-zip.mjs"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { activeRunCompletionFromMessages, assistantMessageHasMeaningfulActivity } from "../src/source/opencode/host.js"
|
|
3
|
+
import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
|
|
4
|
+
import { createLoopExecutor } from "../src/source/runtime/executor.js"
|
|
5
|
+
import { refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "../src/source/runtime/empty-turn.js"
|
|
6
|
+
import { clearSessionActivity } from "../src/source/runtime/session-activity.js"
|
|
7
|
+
|
|
8
|
+
const blank = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [] }
|
|
9
|
+
const whitespace = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: " \n" }] }
|
|
10
|
+
const text = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: "done" }] }
|
|
11
|
+
const tool = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "tool", callID: "call-1", state: { status: "completed" } }] }
|
|
12
|
+
assert.equal(assistantMessageHasMeaningfulActivity(blank), false)
|
|
13
|
+
assert.equal(assistantMessageHasMeaningfulActivity(whitespace), false)
|
|
14
|
+
assert.equal(assistantMessageHasMeaningfulActivity(text), true)
|
|
15
|
+
assert.equal(assistantMessageHasMeaningfulActivity(tool), true)
|
|
16
|
+
|
|
17
|
+
let hostMessages = [blank]
|
|
18
|
+
const hostClient = { session: { messages: async () => ({ data: hostMessages }) } }
|
|
19
|
+
assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-empty", { startedAt: 100 }), "empty")
|
|
20
|
+
hostMessages = [tool, { info: { role: "assistant", time: { created: 140, completed: 150 } }, parts: [] }]
|
|
21
|
+
assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-tool-then-blank", { startedAt: 100 }), "completed", "tool activity earlier in the same logical run must prevent a false empty classification")
|
|
22
|
+
hostMessages = [text]
|
|
23
|
+
assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-text", { startedAt: 100 }), "completed")
|
|
24
|
+
|
|
25
|
+
const pureJob = { id: "pure", enabled: false, paused: false, runCount: 1, maxRuns: 1, lastRunAt: 50 }
|
|
26
|
+
let pure = refundEmptyAssistantTurn(pureJob, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 200)
|
|
27
|
+
assert.equal(pure.job.runCount, 0)
|
|
28
|
+
assert.equal(pure.job.enabled, true)
|
|
29
|
+
assert.equal(pure.job.lastRunAt, 10)
|
|
30
|
+
assert.equal(pure.job.runNowRequestedAt, 200)
|
|
31
|
+
assert.equal(pure.job.emptyTurnCount, 1)
|
|
32
|
+
assert.equal(pure.paused, false)
|
|
33
|
+
pure = refundEmptyAssistantTurn(pure.job, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 300)
|
|
34
|
+
assert.equal(pure.paused, true)
|
|
35
|
+
assert.equal(pure.job.paused, true)
|
|
36
|
+
assert.equal(pure.job.runNowRequestedAt, undefined)
|
|
37
|
+
clearEmptyAssistantTurnStreak(pure.job)
|
|
38
|
+
assert.equal(pure.job.emptyTurnCount, 0)
|
|
39
|
+
assert.equal(pure.job.lastFailureReason, undefined)
|
|
40
|
+
|
|
41
|
+
const statusLogs = []
|
|
42
|
+
const statusActive = new Map([["status-empty", { jobId: "j", job: { id: "j" }, startedAt: 100 }]])
|
|
43
|
+
const statusRuntime = createSessionStatusRuntime({
|
|
44
|
+
activeRuns: statusActive,
|
|
45
|
+
now: () => 10_000,
|
|
46
|
+
sessionStatusCacheMs: 0,
|
|
47
|
+
activeRunCompletionFromMessages: async () => "empty",
|
|
48
|
+
appendLoopLog: async (...args) => statusLogs.push(args),
|
|
49
|
+
})
|
|
50
|
+
const statusClient = { session: { status: async () => ({ data: { "status-empty": { type: "busy" } } }) } }
|
|
51
|
+
assert.equal(await statusRuntime.sessionStatusType(statusClient, "status-empty", "/repo"), "idle", "a host-busy tail that is already an empty completed assistant turn must settle so the empty-turn guard can run")
|
|
52
|
+
assert.ok(statusLogs.some((entry) => entry[1] === "status-message-empty-recovery"))
|
|
53
|
+
clearSessionActivity("status-empty")
|
|
54
|
+
|
|
55
|
+
let clock = 1_000
|
|
56
|
+
let outcome = "empty"
|
|
57
|
+
const states = new Map()
|
|
58
|
+
const schedules = []
|
|
59
|
+
const toasts = []
|
|
60
|
+
const notifications = []
|
|
61
|
+
const checkpoints = []
|
|
62
|
+
const key = (directory, sessionID) => String(directory) + ":" + String(sessionID)
|
|
63
|
+
const clone = (value) => JSON.parse(JSON.stringify(value))
|
|
64
|
+
const workspace = {
|
|
65
|
+
buildPrompt: async () => "continue",
|
|
66
|
+
ensureBranch: async (_directory, job) => job,
|
|
67
|
+
watchChanged: async () => false,
|
|
68
|
+
untilReached: async () => false,
|
|
69
|
+
createCheckpoint: async (...args) => checkpoints.push(args),
|
|
70
|
+
}
|
|
71
|
+
const goalPolicy = {
|
|
72
|
+
runGoalChecks: async (_directory, _sessionID, job) => job,
|
|
73
|
+
applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
|
|
74
|
+
}
|
|
75
|
+
const scheduler = {
|
|
76
|
+
rememberSession: () => {},
|
|
77
|
+
scheduleDueWork: async (...args) => schedules.push(args),
|
|
78
|
+
}
|
|
79
|
+
const executor = createLoopExecutor({
|
|
80
|
+
workspace,
|
|
81
|
+
goalPolicy,
|
|
82
|
+
scheduler,
|
|
83
|
+
now: () => clock,
|
|
84
|
+
readState: async (directory, sessionID) => clone(states.get(key(directory, sessionID)) || { jobs: [] }),
|
|
85
|
+
writeState: async (directory, sessionID, state) => states.set(key(directory, sessionID), clone(state)),
|
|
86
|
+
appendLoopLog: async () => {},
|
|
87
|
+
runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),
|
|
88
|
+
notifyJob: async (...args) => notifications.push(args),
|
|
89
|
+
toast: async (...args) => toasts.push(args),
|
|
90
|
+
fireSdk: () => Promise.resolve({}),
|
|
91
|
+
compactSession: async () => true,
|
|
92
|
+
activeRunCompletionFromMessages: async () => outcome,
|
|
93
|
+
busyRetryMs: 5_000,
|
|
94
|
+
})
|
|
95
|
+
const client = { session: { status: async () => ({ data: {} }), prompt: async () => ({ data: {} }), abort: async () => ({ data: {} }) } }
|
|
96
|
+
const directory = "/repo"
|
|
97
|
+
const sessionID = "empty-run"
|
|
98
|
+
states.set(key(directory, sessionID), { jobs: [{
|
|
99
|
+
id: "job", name: "job", action: "devam et", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 1, maxRuntimeMs: 0, timeoutMs: 0,
|
|
100
|
+
}] })
|
|
101
|
+
|
|
102
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
103
|
+
assert.equal(states.get(key(directory, sessionID)).jobs[0].runCount, 1)
|
|
104
|
+
assert.equal(states.get(key(directory, sessionID)).jobs[0].enabled, false, "max-runs is provisionally consumed at dispatch")
|
|
105
|
+
clock += 100
|
|
106
|
+
assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true)
|
|
107
|
+
let persisted = states.get(key(directory, sessionID)).jobs[0]
|
|
108
|
+
assert.equal(persisted.runCount, 0, "empty completed turn must refund runCount")
|
|
109
|
+
assert.equal(persisted.enabled, true, "empty max-runs attempt must be re-enabled")
|
|
110
|
+
assert.equal(persisted.emptyTurnCount, 1)
|
|
111
|
+
assert.equal(persisted.paused, false)
|
|
112
|
+
assert.equal(checkpoints.length, 0, "empty turn must not run successful finalization/checkpoint work")
|
|
113
|
+
assert.ok(schedules.some((entry) => entry[2] === sessionID && entry[3] === 5_000), "first empty turn retries with bounded delay")
|
|
114
|
+
|
|
115
|
+
executor.markSessionStatus(sessionID, "idle", clock)
|
|
116
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
117
|
+
clock += 100
|
|
118
|
+
assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true)
|
|
119
|
+
persisted = states.get(key(directory, sessionID)).jobs[0]
|
|
120
|
+
assert.equal(persisted.runCount, 0)
|
|
121
|
+
assert.equal(persisted.emptyTurnCount, 2)
|
|
122
|
+
assert.equal(persisted.paused, true, "second consecutive empty completed turn must fail safe instead of looping forever")
|
|
123
|
+
assert.ok(notifications.some((entry) => entry[1]?.id === "job" && entry[2] === "empty_turn"))
|
|
124
|
+
assert.ok(toasts.some((entry) => /paused after 2 consecutive completed assistant turns/i.test(String(entry[1]))))
|
|
125
|
+
|
|
126
|
+
const recoverySession = "empty-then-success"
|
|
127
|
+
outcome = "empty"
|
|
128
|
+
states.set(key(directory, recoverySession), { jobs: [{
|
|
129
|
+
id: "recover", name: "recover", action: "continue", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 0, maxRuntimeMs: 0, timeoutMs: 0,
|
|
130
|
+
}] })
|
|
131
|
+
await executor.maybeRunDueJobs(directory, client, recoverySession)
|
|
132
|
+
clock += 100
|
|
133
|
+
await executor.finalizeActiveRun(directory, client, recoverySession)
|
|
134
|
+
assert.equal(states.get(key(directory, recoverySession)).jobs[0].emptyTurnCount, 1)
|
|
135
|
+
executor.markSessionStatus(recoverySession, "idle", clock)
|
|
136
|
+
outcome = "completed"
|
|
137
|
+
await executor.maybeRunDueJobs(directory, client, recoverySession)
|
|
138
|
+
clock += 100
|
|
139
|
+
await executor.finalizeActiveRun(directory, client, recoverySession)
|
|
140
|
+
const recovered = states.get(key(directory, recoverySession)).jobs[0]
|
|
141
|
+
assert.equal(recovered.emptyTurnCount, 0, "a meaningful completed turn resets the consecutive empty streak")
|
|
142
|
+
assert.equal(recovered.lastFailureReason, undefined)
|
|
143
|
+
assert.equal(recovered.runCount, 1)
|
|
144
|
+
assert.ok(checkpoints.some((entry) => entry[2]?.id === "recover"), "meaningful completion still follows normal finalization")
|
|
145
|
+
|
|
146
|
+
executor.disposeSession(sessionID)
|
|
147
|
+
executor.disposeSession(recoverySession)
|
|
148
|
+
clearSessionActivity(sessionID)
|
|
149
|
+
clearSessionActivity(recoverySession)
|
|
150
|
+
console.log("empty-turn recovery tests passed")
|
|
@@ -61,7 +61,7 @@ assert.equal(compactTuiCommandName("other"), undefined)
|
|
|
61
61
|
"/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 },
|
|
62
62
|
), "incomplete")
|
|
63
63
|
assert.equal(await activeRunCompletionFromMessages(
|
|
64
|
-
"/tmp", messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },
|
|
64
|
+
"/tmp", messagesClient([{ info: { role: "assistant", text: "done", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },
|
|
65
65
|
), "completed")
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -118,7 +118,7 @@ assert.equal(compactTuiCommandName("other"), undefined)
|
|
|
118
118
|
session: {
|
|
119
119
|
messages: async (args) => {
|
|
120
120
|
calls.push(["messages", args])
|
|
121
|
-
return { data: [{ info: { role: "assistant", time: { created: 10, completed: 20 } } }] }
|
|
121
|
+
return { data: [{ info: { role: "assistant", text: "adapter completed", time: { created: 10, completed: 20 } } }] }
|
|
122
122
|
},
|
|
123
123
|
},
|
|
124
124
|
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { createServer } from "node:http"
|
|
4
|
+
import net from "node:net"
|
|
5
|
+
import { spawn } from "node:child_process"
|
|
6
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
|
|
7
|
+
import os from "node:os"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
10
|
+
|
|
11
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
|
+
const isWindows = process.platform === "win32"
|
|
13
|
+
const GOAL_OBJECTIVE = "prove dedicated Goal owns continuation over Loop"
|
|
14
|
+
const GOAL_PROMPT_MARKER = "Continue working toward the active OpenCode goal."
|
|
15
|
+
const LOOP_PROMPT_MARKER = "AUTONOMOUS OPENCODE LOOP ITERATION"
|
|
16
|
+
|
|
17
|
+
function resolveOpenCodeBinary() {
|
|
18
|
+
if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode")
|
|
19
|
+
const candidates = [
|
|
20
|
+
path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"),
|
|
21
|
+
path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"),
|
|
22
|
+
path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"),
|
|
23
|
+
]
|
|
24
|
+
const found = candidates.find((candidate) => existsSync(candidate))
|
|
25
|
+
if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`)
|
|
26
|
+
return found
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const opencodeBin = resolveOpenCodeBinary()
|
|
30
|
+
|
|
31
|
+
function appendLog(current, chunk, limit = 80_000) {
|
|
32
|
+
return (current + String(chunk)).slice(-limit)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function seedConfigDependencies(dir) {
|
|
36
|
+
await mkdir(path.join(dir, "node_modules"), { recursive: true })
|
|
37
|
+
const dependencies = { "@opencode-ai/plugin": "*" }
|
|
38
|
+
await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`)
|
|
39
|
+
await writeFile(
|
|
40
|
+
path.join(dir, "package-lock.json"),
|
|
41
|
+
`${JSON.stringify({
|
|
42
|
+
name: "opencode-loop-goal-coexistence-config",
|
|
43
|
+
lockfileVersion: 3,
|
|
44
|
+
requires: true,
|
|
45
|
+
packages: { "": { dependencies } },
|
|
46
|
+
}, null, 2)}\n`,
|
|
47
|
+
)
|
|
48
|
+
await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function reservePort() {
|
|
52
|
+
return await new Promise((resolve, reject) => {
|
|
53
|
+
const server = net.createServer()
|
|
54
|
+
server.once("error", reject)
|
|
55
|
+
server.listen(0, "127.0.0.1", () => {
|
|
56
|
+
const address = server.address()
|
|
57
|
+
if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port"))
|
|
58
|
+
server.close((error) => error ? reject(error) : resolve(address.port))
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function waitForTcp(port, child, logs, timeoutMs = 30_000) {
|
|
64
|
+
const deadline = Date.now() + timeoutMs
|
|
65
|
+
while (Date.now() < deadline) {
|
|
66
|
+
if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`)
|
|
67
|
+
const connected = await new Promise((resolve) => {
|
|
68
|
+
const socket = net.createConnection({ host: "127.0.0.1", port })
|
|
69
|
+
socket.once("connect", () => { socket.destroy(); resolve(true) })
|
|
70
|
+
socket.once("error", () => resolve(false))
|
|
71
|
+
socket.setTimeout(500, () => { socket.destroy(); resolve(false) })
|
|
72
|
+
})
|
|
73
|
+
if (connected) return
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function stopProcess(child, timeoutMs = 2_000) {
|
|
80
|
+
if (!child || child.exitCode !== null) return
|
|
81
|
+
child.kill()
|
|
82
|
+
await new Promise((resolve) => {
|
|
83
|
+
if (child.exitCode !== null) return resolve()
|
|
84
|
+
const timer = setTimeout(resolve, timeoutMs)
|
|
85
|
+
child.once("close", () => { clearTimeout(timer); resolve() })
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function spawnOpenCode(args, options = {}) {
|
|
90
|
+
return spawn(opencodeBin, args, { ...options, windowsHide: true })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) {
|
|
94
|
+
return await new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawnOpenCode(args, { cwd, env })
|
|
96
|
+
let stdout = ""
|
|
97
|
+
let stderr = ""
|
|
98
|
+
let settled = false
|
|
99
|
+
child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) })
|
|
100
|
+
child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) })
|
|
101
|
+
const finish = (fn, value) => {
|
|
102
|
+
if (settled) return
|
|
103
|
+
settled = true
|
|
104
|
+
clearTimeout(timer)
|
|
105
|
+
fn(value)
|
|
106
|
+
}
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
void stopProcess(child)
|
|
109
|
+
finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
110
|
+
}, timeoutMs)
|
|
111
|
+
child.once("error", (error) => finish(reject, error))
|
|
112
|
+
child.once("close", (code) => {
|
|
113
|
+
if (code !== 0) return finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
114
|
+
finish(resolve, { stdout, stderr })
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function contentText(content) {
|
|
120
|
+
if (typeof content === "string") return content
|
|
121
|
+
if (!Array.isArray(content)) return ""
|
|
122
|
+
return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lastUserMessageText(body) {
|
|
126
|
+
const messages = Array.isArray(body?.messages) ? body.messages : []
|
|
127
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
128
|
+
if (messages[index]?.role === "user") return contentText(messages[index]?.content)
|
|
129
|
+
}
|
|
130
|
+
return contentText(messages.at(-1)?.content)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function streamText(res, content, sequence) {
|
|
134
|
+
const id = `chatcmpl-loop-goal-coexistence-${sequence}`
|
|
135
|
+
const created = Math.floor(Date.now() / 1000)
|
|
136
|
+
res.writeHead(200, {
|
|
137
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
138
|
+
"cache-control": "no-cache",
|
|
139
|
+
connection: "keep-alive",
|
|
140
|
+
})
|
|
141
|
+
const send = (value) => res.write(`data: ${JSON.stringify(value)}\n\n`)
|
|
142
|
+
send({
|
|
143
|
+
id,
|
|
144
|
+
object: "chat.completion.chunk",
|
|
145
|
+
created,
|
|
146
|
+
model: "canary",
|
|
147
|
+
choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }],
|
|
148
|
+
})
|
|
149
|
+
send({
|
|
150
|
+
id,
|
|
151
|
+
object: "chat.completion.chunk",
|
|
152
|
+
created,
|
|
153
|
+
model: "canary",
|
|
154
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
155
|
+
usage: { prompt_tokens: 40, completion_tokens: 4, total_tokens: 44 },
|
|
156
|
+
})
|
|
157
|
+
res.end("data: [DONE]\n\n")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function startProvider() {
|
|
161
|
+
let releaseGoal
|
|
162
|
+
const goalRelease = new Promise((resolve) => { releaseGoal = resolve })
|
|
163
|
+
const stats = { chatRequests: 0, goalRequests: 0, loopRequests: 0, otherRequests: 0, paths: [] }
|
|
164
|
+
const server = createServer(async (req, res) => {
|
|
165
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
166
|
+
stats.paths.push(`${req.method} ${url.pathname}`)
|
|
167
|
+
if (req.method === "GET" && url.pathname.endsWith("/models")) {
|
|
168
|
+
res.writeHead(200, { "content-type": "application/json" })
|
|
169
|
+
res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] }))
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
|
|
173
|
+
res.writeHead(404, { "content-type": "application/json" })
|
|
174
|
+
res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } }))
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let raw = ""
|
|
179
|
+
for await (const chunk of req) raw += String(chunk)
|
|
180
|
+
const body = raw ? JSON.parse(raw) : {}
|
|
181
|
+
const text = lastUserMessageText(body)
|
|
182
|
+
stats.chatRequests += 1
|
|
183
|
+
const sequence = stats.chatRequests
|
|
184
|
+
|
|
185
|
+
if (text.includes(GOAL_PROMPT_MARKER) && text.includes(GOAL_OBJECTIVE)) {
|
|
186
|
+
stats.goalRequests += 1
|
|
187
|
+
if (stats.goalRequests === 1) await goalRelease
|
|
188
|
+
streamText(res, `GOAL_TURN_${stats.goalRequests}`, sequence)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
if (text.includes(LOOP_PROMPT_MARKER)) {
|
|
192
|
+
stats.loopRequests += 1
|
|
193
|
+
streamText(res, `UNEXPECTED_LOOP_TURN_${stats.loopRequests}`, sequence)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
stats.otherRequests += 1
|
|
198
|
+
streamText(res, `OTHER_TURN_${stats.otherRequests}`, sequence)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
stats,
|
|
203
|
+
releaseGoal() { releaseGoal() },
|
|
204
|
+
async listen() {
|
|
205
|
+
await new Promise((resolve, reject) => {
|
|
206
|
+
server.once("error", reject)
|
|
207
|
+
server.listen(0, "127.0.0.1", resolve)
|
|
208
|
+
})
|
|
209
|
+
const address = server.address()
|
|
210
|
+
if (!address || typeof address === "string") throw new Error("failed to start deterministic coexistence provider")
|
|
211
|
+
return address.port
|
|
212
|
+
},
|
|
213
|
+
async close() {
|
|
214
|
+
releaseGoal()
|
|
215
|
+
await new Promise((resolve) => server.close(() => resolve()))
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000) {
|
|
221
|
+
const deadline = Date.now() + timeoutMs
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
if (await predicate()) return
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
225
|
+
}
|
|
226
|
+
const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics
|
|
227
|
+
throw new Error(`timed out waiting for ${description}\n${detail}`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function readActiveGoal(workspace, sessionID) {
|
|
231
|
+
const dir = path.join(workspace, ".opencode", "goals")
|
|
232
|
+
try {
|
|
233
|
+
for (const name of await readdir(dir)) {
|
|
234
|
+
if (!name.endsWith(".json")) continue
|
|
235
|
+
try {
|
|
236
|
+
const value = JSON.parse(await readFile(path.join(dir, name), "utf8"))
|
|
237
|
+
if (value?.sessionID === sessionID) return value
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function main() {
|
|
245
|
+
const goalPackageRoot = path.join(repoRoot, "node_modules", "@bybrawe", "opencode-goal")
|
|
246
|
+
const goalPackageJson = path.join(goalPackageRoot, "package.json")
|
|
247
|
+
assert.ok(existsSync(goalPackageJson), "@bybrawe/opencode-goal must be installed before the coexistence canary")
|
|
248
|
+
const goalPackage = JSON.parse(await readFile(goalPackageJson, "utf8"))
|
|
249
|
+
|
|
250
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-goal-coexistence-"))
|
|
251
|
+
const home = path.join(workspace, ".home")
|
|
252
|
+
const projectConfig = path.join(workspace, ".opencode")
|
|
253
|
+
const globalConfig = path.join(home, ".config", "opencode")
|
|
254
|
+
const pluginDir = path.join(projectConfig, "plugins")
|
|
255
|
+
const commandDir = path.join(projectConfig, "commands")
|
|
256
|
+
const agentDir = path.join(projectConfig, "agents")
|
|
257
|
+
const provider = startProvider()
|
|
258
|
+
const providerPort = await provider.listen()
|
|
259
|
+
let server
|
|
260
|
+
let serverLog = ""
|
|
261
|
+
let goalCommandError = null
|
|
262
|
+
let loopCommandError = null
|
|
263
|
+
|
|
264
|
+
await mkdir(pluginDir, { recursive: true })
|
|
265
|
+
await mkdir(commandDir, { recursive: true })
|
|
266
|
+
await mkdir(agentDir, { recursive: true })
|
|
267
|
+
await seedConfigDependencies(projectConfig)
|
|
268
|
+
await seedConfigDependencies(globalConfig)
|
|
269
|
+
|
|
270
|
+
const loopPluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href
|
|
271
|
+
const goalPackageSpec = pathToFileURL(goalPackageRoot).href
|
|
272
|
+
await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(loopPluginEntry)}\n`)
|
|
273
|
+
await writeFile(path.join(commandDir, "loop.md"), `---\ndescription: Start a coexistence Loop\nagent: opencode-loop-local\n---\n\nOpenCode Loop local command handled. Reply exactly: OK.\n`)
|
|
274
|
+
await writeFile(path.join(agentDir, "opencode-loop-local.md"), `---\ndescription: Local Loop coexistence acknowledgement\nmode: primary\npermission:\n "*": deny\n---\n\nReply exactly: OK\n`)
|
|
275
|
+
await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
|
|
276
|
+
$schema: "https://opencode.ai/config.json",
|
|
277
|
+
plugin: [goalPackageSpec],
|
|
278
|
+
model: "canary/canary",
|
|
279
|
+
small_model: "canary/canary",
|
|
280
|
+
provider: {
|
|
281
|
+
canary: {
|
|
282
|
+
npm: "@ai-sdk/openai-compatible",
|
|
283
|
+
name: "Deterministic Loop Goal Coexistence Canary",
|
|
284
|
+
options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" },
|
|
285
|
+
models: { canary: { name: "Deterministic Loop Goal Coexistence Canary", limit: { context: 100000, output: 4096 } } },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
}, null, 2)}\n`)
|
|
289
|
+
|
|
290
|
+
const env = {
|
|
291
|
+
...process.env,
|
|
292
|
+
HOME: home,
|
|
293
|
+
USERPROFILE: home,
|
|
294
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
295
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
296
|
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
297
|
+
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
298
|
+
OPENCODE_DB: ":memory:",
|
|
299
|
+
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
|
300
|
+
CI: "true",
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 })
|
|
305
|
+
assert.match(prewarm.stdout, /\{[\s\S]*\}/, `OpenCode config prewarm returned no JSON\n${prewarm.stdout}\n${prewarm.stderr}`)
|
|
306
|
+
|
|
307
|
+
const port = await reservePort()
|
|
308
|
+
server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
|
|
309
|
+
server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
310
|
+
server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
311
|
+
await waitForTcp(port, server, () => serverLog)
|
|
312
|
+
|
|
313
|
+
const baseURL = `http://127.0.0.1:${port}`
|
|
314
|
+
const directoryQuery = `directory=${encodeURIComponent(workspace)}`
|
|
315
|
+
const api = async (pathname, init = {}) => {
|
|
316
|
+
const separator = pathname.includes("?") ? "&" : "?"
|
|
317
|
+
const response = await fetch(`${baseURL}${pathname}${separator}${directoryQuery}`, {
|
|
318
|
+
...init,
|
|
319
|
+
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
|
|
320
|
+
signal: init.signal ?? AbortSignal.timeout(30_000),
|
|
321
|
+
})
|
|
322
|
+
const text = await response.text()
|
|
323
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
|
|
324
|
+
if (!text) return null
|
|
325
|
+
try { return JSON.parse(text) } catch { return text }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const sessionsPayload = await api("/session", { method: "GET" })
|
|
329
|
+
assert.ok(Array.isArray(sessionsPayload?.data ?? sessionsPayload), "GET /session bootstrap did not return an array")
|
|
330
|
+
const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "Loop + Goal ownership canary" }) })
|
|
331
|
+
const session = createdPayload?.data ?? createdPayload
|
|
332
|
+
const sessionID = String(session?.id ?? "")
|
|
333
|
+
assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`)
|
|
334
|
+
|
|
335
|
+
const commandPath = `/session/${encodeURIComponent(sessionID)}/command`
|
|
336
|
+
const sendCommand = async (name, argumentsText, signal) => await api(commandPath, {
|
|
337
|
+
method: "POST",
|
|
338
|
+
body: JSON.stringify({ agent: "build", model: "canary/canary", command: name, arguments: argumentsText }),
|
|
339
|
+
signal: signal ?? AbortSignal.timeout(90_000),
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const goalController = new AbortController()
|
|
343
|
+
const goalCommand = sendCommand("goal", `${GOAL_OBJECTIVE} --max-turns 5`, goalController.signal).catch((error) => {
|
|
344
|
+
goalCommandError = error
|
|
345
|
+
return null
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const loopStateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
349
|
+
const loopLogFile = path.join(workspace, ".opencode", "opencode-loop", "loop.log")
|
|
350
|
+
const diagnostics = async () => {
|
|
351
|
+
let goal = "missing"
|
|
352
|
+
let loopState = "missing"
|
|
353
|
+
let loopLog = "missing"
|
|
354
|
+
try { goal = JSON.stringify(await readActiveGoal(workspace, sessionID), null, 2) } catch {}
|
|
355
|
+
try { loopState = await readFile(loopStateFile, "utf8") } catch {}
|
|
356
|
+
try { loopLog = await readFile(loopLogFile, "utf8") } catch {}
|
|
357
|
+
return `goal package=${goalPackage.version}\nprovider=${JSON.stringify(provider.stats)}\ngoalCommandError=${String(goalCommandError ?? "none")}\nloopCommandError=${String(loopCommandError ?? "none")}\ngoal=${goal}\nloopState=${loopState}\nloopLog=${loopLog}\nserver log:\n${serverLog}`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await waitFor(() => provider.stats.goalRequests === 1, "the first dedicated Goal provider turn to be held open", diagnostics)
|
|
361
|
+
await waitFor(async () => {
|
|
362
|
+
const goal = await readActiveGoal(workspace, sessionID)
|
|
363
|
+
return goal?.status === "active" && goal?.objective === GOAL_OBJECTIVE
|
|
364
|
+
}, "released Goal package to persist an active same-session Goal", diagnostics)
|
|
365
|
+
|
|
366
|
+
const loopController = new AbortController()
|
|
367
|
+
const loopCommand = sendCommand("loop", "devam et", loopController.signal).catch((error) => {
|
|
368
|
+
loopCommandError = error
|
|
369
|
+
return null
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
await waitFor(async () => {
|
|
373
|
+
try {
|
|
374
|
+
return (await readFile(loopLogFile, "utf8")).includes("goal-overlap-blocked")
|
|
375
|
+
} catch {
|
|
376
|
+
return false
|
|
377
|
+
}
|
|
378
|
+
}, "Loop to reject prompt ownership while the dedicated Goal is active", diagnostics, 20_000)
|
|
379
|
+
|
|
380
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
381
|
+
assert.equal(provider.stats.goalRequests, 1, `Goal provider turn should remain the only autonomous request while overlap is rejected\n${await diagnostics()}`)
|
|
382
|
+
assert.equal(provider.stats.loopRequests, 0, `Loop must not dispatch while dedicated Goal owns continuation\n${await diagnostics()}`)
|
|
383
|
+
assert.equal(provider.stats.otherRequests, 0, `command bridge must not leak to the model during ownership rejection\n${await diagnostics()}`)
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const loopState = JSON.parse(await readFile(loopStateFile, "utf8"))
|
|
387
|
+
assert.equal(Array.isArray(loopState.jobs) ? loopState.jobs.length : 0, 0, `blocked prompt Loop must not persist a job: ${JSON.stringify(loopState)}`)
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error?.code !== "ENOENT") throw error
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const activeGoal = await readActiveGoal(workspace, sessionID)
|
|
393
|
+
assert.equal(activeGoal?.status, "active", `Goal must remain active after Loop overlap rejection\n${await diagnostics()}`)
|
|
394
|
+
|
|
395
|
+
loopController.abort()
|
|
396
|
+
goalController.abort()
|
|
397
|
+
await Promise.allSettled([loopCommand, goalCommand])
|
|
398
|
+
|
|
399
|
+
console.log(JSON.stringify({
|
|
400
|
+
ok: true,
|
|
401
|
+
platform: process.platform,
|
|
402
|
+
sessionID,
|
|
403
|
+
goalPackageVersion: goalPackage.version,
|
|
404
|
+
provider: provider.stats,
|
|
405
|
+
dedicatedGoal: { id: activeGoal?.id, status: activeGoal?.status },
|
|
406
|
+
loop: { blocked: true, persistedJobs: 0 },
|
|
407
|
+
}, null, 2))
|
|
408
|
+
} finally {
|
|
409
|
+
provider.releaseGoal()
|
|
410
|
+
await stopProcess(server)
|
|
411
|
+
await provider.close().catch(() => undefined)
|
|
412
|
+
await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
main().catch((error) => {
|
|
417
|
+
console.error(error?.stack || error)
|
|
418
|
+
process.exitCode = 1
|
|
419
|
+
})
|
package/src/index.js
CHANGED
|
@@ -801,11 +801,25 @@ function orderedSessionMessages(messages) {
|
|
|
801
801
|
return { message, index, created: Number.isFinite(created) ? created : 0 };
|
|
802
802
|
}).sort((a, b) => a.created - b.created || a.index - b.index).map((entry) => entry.message);
|
|
803
803
|
}
|
|
804
|
+
function assistantMessageHasMeaningfulActivity(message) {
|
|
805
|
+
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
806
|
+
for (const part of parts) {
|
|
807
|
+
if (!part || typeof part !== "object")
|
|
808
|
+
continue;
|
|
809
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim())
|
|
810
|
+
return true;
|
|
811
|
+
if (["tool", "file", "patch", "artifact"].includes(String(part.type || "")))
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
const info = message?.info || message || {};
|
|
815
|
+
return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim());
|
|
816
|
+
}
|
|
804
817
|
async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
|
|
805
818
|
const messages = await readRecentSessionMessages(client, sessionID, directory);
|
|
806
819
|
if (!messages)
|
|
807
820
|
return "unknown";
|
|
808
|
-
const
|
|
821
|
+
const ordered = orderedSessionMessages(messages);
|
|
822
|
+
const tail = ordered.at(-1);
|
|
809
823
|
const info = tail?.info || tail;
|
|
810
824
|
if (!info || info.role !== "assistant")
|
|
811
825
|
return "incomplete";
|
|
@@ -816,7 +830,17 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act
|
|
|
816
830
|
const startedAt = Number(active?.startedAt || 0);
|
|
817
831
|
if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt))
|
|
818
832
|
return "incomplete";
|
|
819
|
-
|
|
833
|
+
const relevant = ordered.filter((message) => {
|
|
834
|
+
const candidate = message?.info || message || {};
|
|
835
|
+
if (candidate.role !== "assistant")
|
|
836
|
+
return false;
|
|
837
|
+
if (startedAt <= 0)
|
|
838
|
+
return true;
|
|
839
|
+
const candidateCreated = Number(candidate?.time?.created || 0);
|
|
840
|
+
const candidateCompleted = Number(candidate?.time?.completed || 0);
|
|
841
|
+
return candidateCreated >= startedAt || candidateCompleted >= startedAt;
|
|
842
|
+
});
|
|
843
|
+
return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty";
|
|
820
844
|
}
|
|
821
845
|
async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
|
|
822
846
|
const preferred = normalizedModelRef(preferredModel);
|
|
@@ -2853,6 +2877,9 @@ function nonNegativeNumber(value, fallback) {
|
|
|
2853
2877
|
const number = Number(value);
|
|
2854
2878
|
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
|
2855
2879
|
}
|
|
2880
|
+
function settledAssistantCompletion(value) {
|
|
2881
|
+
return value === "completed" || value === "empty";
|
|
2882
|
+
}
|
|
2856
2883
|
function createSessionStatusRuntime(options = {}) {
|
|
2857
2884
|
const activeRuns = options.activeRuns;
|
|
2858
2885
|
if (!(activeRuns instanceof Map))
|
|
@@ -2947,7 +2974,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2947
2974
|
if (!options2.requireIdle && !options2.forceStale)
|
|
2948
2975
|
return true;
|
|
2949
2976
|
const completion = options2.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
|
|
2950
|
-
if (completion
|
|
2977
|
+
if (settledAssistantCompletion(completion))
|
|
2951
2978
|
return true;
|
|
2952
2979
|
if (!options2.requireIdle)
|
|
2953
2980
|
return completion === "unknown" && staleActiveRun(sessionID);
|
|
@@ -2977,7 +3004,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2977
3004
|
if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
|
|
2978
3005
|
return false;
|
|
2979
3006
|
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 });
|
|
2980
|
-
if (completion
|
|
3007
|
+
if (!settledAssistantCompletion(completion))
|
|
2981
3008
|
return false;
|
|
2982
3009
|
markSessionStatus(sessionID, "idle");
|
|
2983
3010
|
await appendLoopLog2(directory, "status-message-idle-recovery", {
|
|
@@ -3012,7 +3039,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3012
3039
|
const active = activeRuns.get(sessionID);
|
|
3013
3040
|
if (active) {
|
|
3014
3041
|
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
|
|
3015
|
-
if (completion
|
|
3042
|
+
if (settledAssistantCompletion(completion) || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) {
|
|
3016
3043
|
markSessionStatus(sessionID, "idle");
|
|
3017
3044
|
const logDetails = {
|
|
3018
3045
|
sessionID,
|
|
@@ -3020,7 +3047,8 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3020
3047
|
startedAt: active.startedAt,
|
|
3021
3048
|
...completion === "completed" ? {} : { staleStatus: live.type }
|
|
3022
3049
|
};
|
|
3023
|
-
|
|
3050
|
+
const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
|
|
3051
|
+
await appendLoopLog2(directory, recoveryEvent, logDetails);
|
|
3024
3052
|
return "idle";
|
|
3025
3053
|
}
|
|
3026
3054
|
}
|
|
@@ -3042,6 +3070,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3042
3070
|
staleActiveRun,
|
|
3043
3071
|
canFinalizeActiveRun,
|
|
3044
3072
|
readLiveSessionStatus,
|
|
3073
|
+
activeRunCompletion: activeRunCompletionFromMessages2,
|
|
3045
3074
|
sessionStatusType,
|
|
3046
3075
|
sessionIsIdle
|
|
3047
3076
|
};
|
|
@@ -3532,6 +3561,50 @@ function refundInfrastructureRun(job, snapshot = {}, input = {}) {
|
|
|
3532
3561
|
return job;
|
|
3533
3562
|
}
|
|
3534
3563
|
|
|
3564
|
+
// src/source/runtime/empty-turn.js
|
|
3565
|
+
var DEFAULT_MAX_EMPTY_TURNS = 2;
|
|
3566
|
+
function guardsEmptyAssistantTurn(job) {
|
|
3567
|
+
const kind = actionKind(job?.action, job || {});
|
|
3568
|
+
return kind === "prompt" || kind === "goal";
|
|
3569
|
+
}
|
|
3570
|
+
function emptyTurnLimit(job) {
|
|
3571
|
+
const configured = Number(job?.maxEmptyTurns || 0);
|
|
3572
|
+
if (Number.isFinite(configured) && configured > 0)
|
|
3573
|
+
return Math.max(1, Math.floor(configured));
|
|
3574
|
+
return DEFAULT_MAX_EMPTY_TURNS;
|
|
3575
|
+
}
|
|
3576
|
+
function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {
|
|
3577
|
+
const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0);
|
|
3578
|
+
const currentCount = Number(job?.runCount || 0);
|
|
3579
|
+
if (chargedCount > 0 && currentCount >= chargedCount)
|
|
3580
|
+
job.runCount = Math.max(0, currentCount - 1);
|
|
3581
|
+
if (Number.isFinite(Number(active?.previousLastRunAt)))
|
|
3582
|
+
job.lastRunAt = Number(active.previousLastRunAt);
|
|
3583
|
+
if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {
|
|
3584
|
+
job.enabled = true;
|
|
3585
|
+
}
|
|
3586
|
+
job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1;
|
|
3587
|
+
job.lastEmptyTurnAt = Number(timestamp) || Date.now();
|
|
3588
|
+
job.lastFailureReason = "empty_turn";
|
|
3589
|
+
const limit = emptyTurnLimit(job);
|
|
3590
|
+
const paused = job.emptyTurnCount >= limit;
|
|
3591
|
+
if (paused) {
|
|
3592
|
+
job.paused = true;
|
|
3593
|
+
delete job.runNowRequestedAt;
|
|
3594
|
+
} else {
|
|
3595
|
+
job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now());
|
|
3596
|
+
}
|
|
3597
|
+
return { job, paused, count: job.emptyTurnCount, limit };
|
|
3598
|
+
}
|
|
3599
|
+
function clearEmptyAssistantTurnStreak(job) {
|
|
3600
|
+
if (!job)
|
|
3601
|
+
return job;
|
|
3602
|
+
job.emptyTurnCount = 0;
|
|
3603
|
+
if (job.lastFailureReason === "empty_turn")
|
|
3604
|
+
delete job.lastFailureReason;
|
|
3605
|
+
return job;
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3535
3608
|
// src/source/runtime/executor.js
|
|
3536
3609
|
var DEFAULT_ACTIVE_GUARD_MS = 45000;
|
|
3537
3610
|
var DEFAULT_BUSY_RETRY_MS2 = 5000;
|
|
@@ -3585,6 +3658,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3585
3658
|
updateSessionStatusFromEvent,
|
|
3586
3659
|
staleActiveRun,
|
|
3587
3660
|
canFinalizeActiveRun,
|
|
3661
|
+
activeRunCompletion,
|
|
3588
3662
|
sessionStatusType,
|
|
3589
3663
|
sessionIsIdle,
|
|
3590
3664
|
markSessionStatus,
|
|
@@ -3760,6 +3834,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3760
3834
|
return;
|
|
3761
3835
|
if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions))
|
|
3762
3836
|
return false;
|
|
3837
|
+
const completion = await activeRunCompletion(directory, client, sessionID, active);
|
|
3763
3838
|
const recoveredStale = staleActiveRun(sessionID);
|
|
3764
3839
|
if (active.compactionOnly) {
|
|
3765
3840
|
const pending = compactionRuntime.getPending(sessionID);
|
|
@@ -3780,6 +3855,30 @@ function createLoopExecutor(options = {}) {
|
|
|
3780
3855
|
if (!job)
|
|
3781
3856
|
return;
|
|
3782
3857
|
job.lastFinishedAt = now2();
|
|
3858
|
+
if (completion === "empty" && guardsEmptyAssistantTurn(job)) {
|
|
3859
|
+
const empty = refundEmptyAssistantTurn(job, active, now2());
|
|
3860
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3861
|
+
await writeState2(directory, sessionID, state);
|
|
3862
|
+
await appendLoopLog2(directory, "empty-assistant-turn", {
|
|
3863
|
+
sessionID,
|
|
3864
|
+
job: job.name || job.id,
|
|
3865
|
+
count: empty.count,
|
|
3866
|
+
limit: empty.limit,
|
|
3867
|
+
paused: empty.paused,
|
|
3868
|
+
refunded: true
|
|
3869
|
+
});
|
|
3870
|
+
if (empty.paused) {
|
|
3871
|
+
await notifyJob2(directory, job, "empty_turn");
|
|
3872
|
+
await toast2(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning");
|
|
3873
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
3874
|
+
} else {
|
|
3875
|
+
await toast2(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning");
|
|
3876
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
3877
|
+
}
|
|
3878
|
+
return true;
|
|
3879
|
+
}
|
|
3880
|
+
if (completion === "completed")
|
|
3881
|
+
clearEmptyAssistantTurnStreak(job);
|
|
3783
3882
|
if (recoveredStale) {
|
|
3784
3883
|
await appendLoopLog2(directory, "active-stale-recovery", {
|
|
3785
3884
|
sessionID,
|
|
@@ -54,10 +54,22 @@ export function orderedSessionMessages(messages) {
|
|
|
54
54
|
.map((entry) => entry.message)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export function assistantMessageHasMeaningfulActivity(message) {
|
|
58
|
+
const parts = Array.isArray(message?.parts) ? message.parts : []
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
if (!part || typeof part !== "object") continue
|
|
61
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) return true
|
|
62
|
+
if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) return true
|
|
63
|
+
}
|
|
64
|
+
const info = message?.info || message || {}
|
|
65
|
+
return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim())
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
export async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
|
|
58
69
|
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
59
70
|
if (!messages) return "unknown"
|
|
60
|
-
const
|
|
71
|
+
const ordered = orderedSessionMessages(messages)
|
|
72
|
+
const tail = ordered.at(-1)
|
|
61
73
|
const info = tail?.info || tail
|
|
62
74
|
if (!info || info.role !== "assistant") return "incomplete"
|
|
63
75
|
const completed = Number(info?.time?.completed || 0)
|
|
@@ -65,7 +77,16 @@ export async function activeRunCompletionFromMessages(directory, client, session
|
|
|
65
77
|
if (!Number.isFinite(completed) || completed <= 0) return "incomplete"
|
|
66
78
|
const startedAt = Number(active?.startedAt || 0)
|
|
67
79
|
if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"
|
|
68
|
-
|
|
80
|
+
|
|
81
|
+
const relevant = ordered.filter((message) => {
|
|
82
|
+
const candidate = message?.info || message || {}
|
|
83
|
+
if (candidate.role !== "assistant") return false
|
|
84
|
+
if (startedAt <= 0) return true
|
|
85
|
+
const candidateCreated = Number(candidate?.time?.created || 0)
|
|
86
|
+
const candidateCompleted = Number(candidate?.time?.completed || 0)
|
|
87
|
+
return candidateCreated >= startedAt || candidateCompleted >= startedAt
|
|
88
|
+
})
|
|
89
|
+
return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty"
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { actionKind } from "../core/jobs.js"
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MAX_EMPTY_TURNS = 2
|
|
4
|
+
|
|
5
|
+
export function guardsEmptyAssistantTurn(job) {
|
|
6
|
+
const kind = actionKind(job?.action, job || {})
|
|
7
|
+
return kind === "prompt" || kind === "goal"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function emptyTurnLimit(job) {
|
|
11
|
+
const configured = Number(job?.maxEmptyTurns || 0)
|
|
12
|
+
if (Number.isFinite(configured) && configured > 0) return Math.max(1, Math.floor(configured))
|
|
13
|
+
return DEFAULT_MAX_EMPTY_TURNS
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {
|
|
17
|
+
const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0)
|
|
18
|
+
const currentCount = Number(job?.runCount || 0)
|
|
19
|
+
if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1)
|
|
20
|
+
|
|
21
|
+
if (Number.isFinite(Number(active?.previousLastRunAt))) job.lastRunAt = Number(active.previousLastRunAt)
|
|
22
|
+
if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {
|
|
23
|
+
job.enabled = true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1
|
|
27
|
+
job.lastEmptyTurnAt = Number(timestamp) || Date.now()
|
|
28
|
+
job.lastFailureReason = "empty_turn"
|
|
29
|
+
|
|
30
|
+
const limit = emptyTurnLimit(job)
|
|
31
|
+
const paused = job.emptyTurnCount >= limit
|
|
32
|
+
if (paused) {
|
|
33
|
+
job.paused = true
|
|
34
|
+
delete job.runNowRequestedAt
|
|
35
|
+
} else {
|
|
36
|
+
job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now())
|
|
37
|
+
}
|
|
38
|
+
return { job, paused, count: job.emptyTurnCount, limit }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearEmptyAssistantTurnStreak(job) {
|
|
42
|
+
if (!job) return job
|
|
43
|
+
job.emptyTurnCount = 0
|
|
44
|
+
if (job.lastFailureReason === "empty_turn") delete job.lastFailureReason
|
|
45
|
+
return job
|
|
46
|
+
}
|
|
@@ -11,6 +11,7 @@ import { createActionDispatcher } from "./action-dispatch.js"
|
|
|
11
11
|
import { createRunFinalizationRuntime } from "./run-finalization.js"
|
|
12
12
|
import { createRunAdmissionRuntime } from "./run-admission.js"
|
|
13
13
|
import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"
|
|
14
|
+
import { guardsEmptyAssistantTurn, refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "./empty-turn.js"
|
|
14
15
|
|
|
15
16
|
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
16
17
|
const DEFAULT_BUSY_RETRY_MS = 5_000
|
|
@@ -77,6 +78,7 @@ export function createLoopExecutor(options = {}) {
|
|
|
77
78
|
updateSessionStatusFromEvent,
|
|
78
79
|
staleActiveRun,
|
|
79
80
|
canFinalizeActiveRun,
|
|
81
|
+
activeRunCompletion,
|
|
80
82
|
sessionStatusType,
|
|
81
83
|
sessionIsIdle,
|
|
82
84
|
markSessionStatus,
|
|
@@ -267,6 +269,7 @@ export function createLoopExecutor(options = {}) {
|
|
|
267
269
|
const active = activeRuns.get(sessionID)
|
|
268
270
|
if (!active) return
|
|
269
271
|
if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false
|
|
272
|
+
const completion = await activeRunCompletion(directory, client, sessionID, active)
|
|
270
273
|
const recoveredStale = staleActiveRun(sessionID)
|
|
271
274
|
if (active.compactionOnly) {
|
|
272
275
|
const pending = compactionRuntime.getPending(sessionID)
|
|
@@ -287,6 +290,31 @@ export function createLoopExecutor(options = {}) {
|
|
|
287
290
|
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
288
291
|
if (!job) return
|
|
289
292
|
job.lastFinishedAt = now()
|
|
293
|
+
|
|
294
|
+
if (completion === "empty" && guardsEmptyAssistantTurn(job)) {
|
|
295
|
+
const empty = refundEmptyAssistantTurn(job, active, now())
|
|
296
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
297
|
+
await writeState(directory, sessionID, state)
|
|
298
|
+
await appendLoopLog(directory, "empty-assistant-turn", {
|
|
299
|
+
sessionID,
|
|
300
|
+
job: job.name || job.id,
|
|
301
|
+
count: empty.count,
|
|
302
|
+
limit: empty.limit,
|
|
303
|
+
paused: empty.paused,
|
|
304
|
+
refunded: true,
|
|
305
|
+
})
|
|
306
|
+
if (empty.paused) {
|
|
307
|
+
await notifyJob(directory, job, "empty_turn")
|
|
308
|
+
await toast(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning")
|
|
309
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
310
|
+
} else {
|
|
311
|
+
await toast(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning")
|
|
312
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
313
|
+
}
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (completion === "completed") clearEmptyAssistantTurnStreak(job)
|
|
290
318
|
if (recoveredStale) {
|
|
291
319
|
await appendLoopLog(directory, "active-stale-recovery", {
|
|
292
320
|
sessionID,
|
|
@@ -24,6 +24,10 @@ function nonNegativeNumber(value, fallback) {
|
|
|
24
24
|
return Number.isFinite(number) && number >= 0 ? number : fallback
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function settledAssistantCompletion(value) {
|
|
28
|
+
return value === "completed" || value === "empty"
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
export function createSessionStatusRuntime(options = {}) {
|
|
28
32
|
const activeRuns = options.activeRuns
|
|
29
33
|
if (!(activeRuns instanceof Map)) throw new TypeError("createSessionStatusRuntime requires activeRuns Map")
|
|
@@ -114,7 +118,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
114
118
|
const completion = options.forceStale
|
|
115
119
|
? await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
116
120
|
: undefined
|
|
117
|
-
if (completion
|
|
121
|
+
if (settledAssistantCompletion(completion)) return true
|
|
118
122
|
if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
|
|
119
123
|
|
|
120
124
|
const cached = sessionStatuses.get(sessionID)
|
|
@@ -142,7 +146,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
142
146
|
if (liveType !== "busy") return false
|
|
143
147
|
if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false
|
|
144
148
|
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 })
|
|
145
|
-
if (completion
|
|
149
|
+
if (!settledAssistantCompletion(completion)) return false
|
|
146
150
|
markSessionStatus(sessionID, "idle")
|
|
147
151
|
await appendLoopLog(directory, "status-message-idle-recovery", {
|
|
148
152
|
sessionID,
|
|
@@ -177,7 +181,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
177
181
|
const active = activeRuns.get(sessionID)
|
|
178
182
|
if (active) {
|
|
179
183
|
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
180
|
-
if (completion
|
|
184
|
+
if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {
|
|
181
185
|
markSessionStatus(sessionID, "idle")
|
|
182
186
|
const logDetails = {
|
|
183
187
|
sessionID,
|
|
@@ -185,11 +189,12 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
185
189
|
startedAt: active.startedAt,
|
|
186
190
|
...(completion === "completed" ? {} : { staleStatus: live.type }),
|
|
187
191
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
completion === "completed"
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
const recoveryEvent = completion === "empty"
|
|
193
|
+
? "status-message-empty-recovery"
|
|
194
|
+
: completion === "completed"
|
|
195
|
+
? "status-message-complete-recovery"
|
|
196
|
+
: "status-stale-recovery"
|
|
197
|
+
await appendLoopLog(directory, recoveryEvent, logDetails)
|
|
193
198
|
return "idle"
|
|
194
199
|
}
|
|
195
200
|
}
|
|
@@ -214,6 +219,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
214
219
|
staleActiveRun,
|
|
215
220
|
canFinalizeActiveRun,
|
|
216
221
|
readLiveSessionStatus,
|
|
222
|
+
activeRunCompletion: activeRunCompletionFromMessages,
|
|
217
223
|
sessionStatusType,
|
|
218
224
|
sessionIsIdle,
|
|
219
225
|
}
|