@bybrawe/opencode-loop 0.5.34 → 0.5.35
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 +3 -3
- package/scripts/network-recovery-test.mjs +218 -0
- package/scripts/session-status-test.mjs +18 -4
- package/src/index.js +319 -29
- package/src/source/core/continuation.js +53 -2
- package/src/source/runtime/executor.js +145 -16
- package/src/source/runtime/network-recovery.js +40 -0
- package/src/source/runtime/run-finalization.js +28 -0
- package/src/source/runtime/session-status.js +29 -30
- package/src/source/runtime/terminal-guard.js +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.35
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Recover transient Loop-owned network/provider failures with bounded exponential backoff (5s to 60s), refund failed infrastructure attempts from `runCount`/`lastRunAt`, re-enable jobs that only hit `--max-runs` because of the failed attempt, and keep infrastructure failures separate from ordinary `failureCount`.
|
|
7
|
+
- Add a two-minute watchdog for an explicitly stuck OpenCode retry, scoped only to a Loop-owned active turn: abort that owned retry, refund the logical run, and reschedule with backoff without touching unrelated foreground/user work. Successful normal turns reset the consecutive network-backoff counter.
|
|
8
|
+
- Preserve plain `/loop devam et` as intentionally unlimited, while explicit completion-bounded idle loops such as `bitene kadar`, `projeyi bitir`, and `until done` now auto-pause only after two consecutive current assistant replies both report verified completion and no remaining work. Replies naming next/remaining work reset the terminal signal.
|
|
9
|
+
- Make Turkish terminal/no-work detection Unicode-safe, expand outage/terminal regressions, keep the generated single-file stable bundle synchronized with modular source, and document retry, backoff, watchdog, and completion-bounded scheduling behavior in `docs/SCHEDULING.md`.
|
|
10
|
+
|
|
3
11
|
## 0.5.34
|
|
4
12
|
|
|
5
13
|
- Make plain `/loop <prompt>` the ergonomic unlimited idle-continuation form: when the assistant stops and the session is safely idle, Loop sends the prompt again. Add explicit `idle`, recurring `every <duration>`, and one-shot `after/in <duration>` schedule grammar while preserving legacy duration-first commands.
|
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.35`.**
|
|
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.35",
|
|
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/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/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/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/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",
|
|
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,218 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../src/source/core/continuation.js"
|
|
3
|
+
import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "../src/source/runtime/network-recovery.js"
|
|
4
|
+
import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
|
|
5
|
+
import { applyTerminalContinuationGuard } from "../src/source/runtime/terminal-guard.js"
|
|
6
|
+
import { createLoopExecutor } from "../src/source/runtime/executor.js"
|
|
7
|
+
import { clearSessionActivity, sessionStatuses, sessionStatusSeenAt } from "../src/source/runtime/session-activity.js"
|
|
8
|
+
|
|
9
|
+
function clone(value) { return JSON.parse(JSON.stringify(value)) }
|
|
10
|
+
|
|
11
|
+
for (const value of [
|
|
12
|
+
"fetch failed",
|
|
13
|
+
"network connection lost",
|
|
14
|
+
"ECONNRESET",
|
|
15
|
+
"EAI_AGAIN",
|
|
16
|
+
"ETIMEDOUT",
|
|
17
|
+
"socket hang up",
|
|
18
|
+
"HTTP 503 service unavailable",
|
|
19
|
+
]) assert.equal(isTransientNetworkError(value), true, value)
|
|
20
|
+
assert.equal(isTransientNetworkError("HTTP 401 invalid API key"), false)
|
|
21
|
+
assert.equal(networkRetryDelayMs(1, 5_000, 60_000), 5_000)
|
|
22
|
+
assert.equal(networkRetryDelayMs(2, 5_000, 60_000), 10_000)
|
|
23
|
+
assert.equal(networkRetryDelayMs(9, 5_000, 60_000), 60_000)
|
|
24
|
+
|
|
25
|
+
const refunded = {
|
|
26
|
+
id: "limited",
|
|
27
|
+
runCount: 3,
|
|
28
|
+
maxRuns: 3,
|
|
29
|
+
enabled: false,
|
|
30
|
+
lastRunAt: 500,
|
|
31
|
+
failureCount: 2,
|
|
32
|
+
}
|
|
33
|
+
refundInfrastructureRun(refunded, {
|
|
34
|
+
runCount: 3,
|
|
35
|
+
previousLastRunAt: 250,
|
|
36
|
+
disabledByMaxRuns: true,
|
|
37
|
+
}, {
|
|
38
|
+
reason: "dispatch_failed_transient",
|
|
39
|
+
error: new Error("fetch failed: ECONNRESET"),
|
|
40
|
+
now: 1_000,
|
|
41
|
+
})
|
|
42
|
+
assert.equal(refunded.runCount, 2, "transient infrastructure attempt must not consume max-runs")
|
|
43
|
+
assert.equal(refunded.enabled, true, "max-runs disable caused by a transient attempt must be rolled back")
|
|
44
|
+
assert.equal(refunded.lastRunAt, 250)
|
|
45
|
+
assert.equal(refunded.failureCount, 2, "transient outage must not consume ordinary failure budget")
|
|
46
|
+
assert.equal(refunded.infrastructureFailureCount, 1)
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
let clock = 100_000
|
|
50
|
+
const activeRuns = new Map([["retry-session", { startedAt: 1, jobId: "j", job: { id: "j" } }]])
|
|
51
|
+
const logs = []
|
|
52
|
+
const runtime = createSessionStatusRuntime({
|
|
53
|
+
activeRuns,
|
|
54
|
+
now: () => clock,
|
|
55
|
+
staleActiveRecoveryMs: 1_000,
|
|
56
|
+
sessionStatusCacheMs: 0,
|
|
57
|
+
activeRunCompletionFromMessages: async () => "unknown",
|
|
58
|
+
appendLoopLog: async (...args) => logs.push(args),
|
|
59
|
+
})
|
|
60
|
+
const retryClient = { session: { status: async () => ({ data: { "retry-session": { type: "retry" } } }) } }
|
|
61
|
+
assert.equal(await runtime.sessionStatusType(retryClient, "retry-session", "/repo"), "retry")
|
|
62
|
+
clock += 120_000
|
|
63
|
+
assert.equal(await runtime.sessionStatusType(retryClient, "retry-session", "/repo"), "retry", "stale retry must never be converted to idle by age alone")
|
|
64
|
+
assert.equal(logs.some((entry) => entry[1] === "status-stale-recovery"), false)
|
|
65
|
+
clearSessionActivity("retry-session")
|
|
66
|
+
sessionStatuses.delete("retry-session")
|
|
67
|
+
sessionStatusSeenAt.delete("retry-session")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
let clock = 100_000
|
|
72
|
+
const activeRuns = new Map([["offline-session", { startedAt: 1, jobId: "j", job: { id: "j" } }]])
|
|
73
|
+
const runtime = createSessionStatusRuntime({
|
|
74
|
+
activeRuns,
|
|
75
|
+
now: () => clock,
|
|
76
|
+
staleActiveRecoveryMs: 1_000,
|
|
77
|
+
sessionStatusCacheMs: 0,
|
|
78
|
+
activeRunCompletionFromMessages: async () => "unknown",
|
|
79
|
+
appendLoopLog: async () => {},
|
|
80
|
+
})
|
|
81
|
+
const offlineClient = { session: { status: async () => { throw new Error("network connection lost") } } }
|
|
82
|
+
clock += 120_000
|
|
83
|
+
assert.equal(await runtime.sessionStatusType(offlineClient, "offline-session", "/repo"), "busy", "failed status reads must fail closed, not become idle")
|
|
84
|
+
clearSessionActivity("offline-session")
|
|
85
|
+
sessionStatuses.delete("offline-session")
|
|
86
|
+
sessionStatusSeenAt.delete("offline-session")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
assert.equal(isCompletionBoundedContinuation("devam et"), false)
|
|
90
|
+
assert.equal(isCompletionBoundedContinuation("devam et bitene kadar devam tamamen projeyi bitir"), true)
|
|
91
|
+
assert.equal(isTerminalNoWorkReply("Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata."), true)
|
|
92
|
+
assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build doğrulaması."), false)
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
const terminalMessages = [{
|
|
96
|
+
info: { role: "assistant", time: { created: 2_000, completed: 2_100 } },
|
|
97
|
+
parts: [{ type: "text", text: "Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata." }],
|
|
98
|
+
}]
|
|
99
|
+
const client = { session: { messages: async () => ({ data: terminalMessages }) } }
|
|
100
|
+
const bounded = {
|
|
101
|
+
id: "bounded",
|
|
102
|
+
action: "devam et bitene kadar devam tamamen projeyi bitir",
|
|
103
|
+
scheduleMode: "idle",
|
|
104
|
+
lastRunAt: 1_500,
|
|
105
|
+
paused: false,
|
|
106
|
+
}
|
|
107
|
+
let result = await applyTerminalContinuationGuard("/repo", client, "s", bounded)
|
|
108
|
+
assert.equal(result.terminal, true)
|
|
109
|
+
assert.equal(result.pausedNow, false)
|
|
110
|
+
assert.equal(result.job.terminalNoWorkCount, 1)
|
|
111
|
+
result = await applyTerminalContinuationGuard("/repo", client, "s", result.job)
|
|
112
|
+
assert.equal(result.pausedNow, true, "two current terminal replies should stop an explicit until-done loop")
|
|
113
|
+
assert.equal(result.job.paused, true)
|
|
114
|
+
|
|
115
|
+
const infinite = {
|
|
116
|
+
id: "infinite",
|
|
117
|
+
action: "devam et",
|
|
118
|
+
scheduleMode: "idle",
|
|
119
|
+
lastRunAt: 1_500,
|
|
120
|
+
paused: false,
|
|
121
|
+
}
|
|
122
|
+
const untouched = await applyTerminalContinuationGuard("/repo", client, "s", infinite)
|
|
123
|
+
assert.equal(untouched.terminal, false)
|
|
124
|
+
assert.equal(untouched.job.paused, false, "plain /loop devam et must remain intentionally infinite")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
let clock = 1_000_000
|
|
129
|
+
let liveStatus = "idle"
|
|
130
|
+
const states = new Map()
|
|
131
|
+
const schedules = []
|
|
132
|
+
const fireCalls = []
|
|
133
|
+
const directory = "/repo"
|
|
134
|
+
const sessionID = "watchdog-session"
|
|
135
|
+
const stateKey = `${directory}:${sessionID}`
|
|
136
|
+
states.set(stateKey, { jobs: [{
|
|
137
|
+
id: "job",
|
|
138
|
+
name: "job",
|
|
139
|
+
action: "devam et",
|
|
140
|
+
scheduleMode: "idle",
|
|
141
|
+
enabled: true,
|
|
142
|
+
paused: false,
|
|
143
|
+
intervalMs: 0,
|
|
144
|
+
runCount: 0,
|
|
145
|
+
maxRuns: 1,
|
|
146
|
+
maxRuntimeMs: 0,
|
|
147
|
+
timeoutMs: 0,
|
|
148
|
+
noOverlap: true,
|
|
149
|
+
}] })
|
|
150
|
+
|
|
151
|
+
const executor = createLoopExecutor({
|
|
152
|
+
workspace: {
|
|
153
|
+
buildPrompt: async (_directory, job) => job.action,
|
|
154
|
+
ensureBranch: async (_directory, job) => job,
|
|
155
|
+
watchChanged: async () => false,
|
|
156
|
+
untilReached: async () => false,
|
|
157
|
+
createCheckpoint: async () => {},
|
|
158
|
+
},
|
|
159
|
+
goalPolicy: {
|
|
160
|
+
runGoalChecks: async (_directory, _sessionID, job) => job,
|
|
161
|
+
applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
|
|
162
|
+
},
|
|
163
|
+
scheduler: {
|
|
164
|
+
rememberSession: () => {},
|
|
165
|
+
scheduleDueWork: async (...args) => schedules.push(args),
|
|
166
|
+
},
|
|
167
|
+
now: () => clock,
|
|
168
|
+
readState: async () => clone(states.get(stateKey)),
|
|
169
|
+
writeState: async (_directory, _sessionID, state) => states.set(stateKey, clone(state)),
|
|
170
|
+
appendLoopLog: async () => {},
|
|
171
|
+
notifyJob: async () => {},
|
|
172
|
+
toast: async () => {},
|
|
173
|
+
runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),
|
|
174
|
+
fireSdk: (...args) => { fireCalls.push(args); return Promise.resolve({ data: {} }) },
|
|
175
|
+
sdkCall: async () => ({}),
|
|
176
|
+
normalizedModelRef: () => undefined,
|
|
177
|
+
compactTuiCommandName: () => undefined,
|
|
178
|
+
guardLoopOwnedUserMessage: () => {},
|
|
179
|
+
dangerousShell: () => false,
|
|
180
|
+
activeRunCompletionFromMessages: async () => "unknown",
|
|
181
|
+
busyRetryMs: 5,
|
|
182
|
+
providerRetryWatchdogMs: 20,
|
|
183
|
+
networkRetryMaxMs: 100,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const client = {
|
|
187
|
+
session: {
|
|
188
|
+
status: async () => ({ data: liveStatus === "idle" ? {} : { [sessionID]: { type: liveStatus } } }),
|
|
189
|
+
prompt: async () => ({ data: {} }),
|
|
190
|
+
abort: async () => ({ data: {} }),
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
195
|
+
assert.ok(executor.getActiveRun(sessionID), "first logical run should be active")
|
|
196
|
+
assert.equal(states.get(stateKey).jobs[0].runCount, 1)
|
|
197
|
+
assert.equal(states.get(stateKey).jobs[0].enabled, false, "max-runs can temporarily disable while the real turn is in flight")
|
|
198
|
+
|
|
199
|
+
liveStatus = "retry"
|
|
200
|
+
clock += 5
|
|
201
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
202
|
+
assert.ok(executor.getActiveRun(sessionID), "first retry observation should remain host-owned")
|
|
203
|
+
clock += 25
|
|
204
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
205
|
+
|
|
206
|
+
assert.equal(executor.getActiveRun(sessionID), undefined, "stuck Loop-owned retry should be released after watchdog")
|
|
207
|
+
const job = states.get(stateKey).jobs[0]
|
|
208
|
+
assert.equal(job.runCount, 0, "network watchdog recovery must refund the logical run")
|
|
209
|
+
assert.equal(job.enabled, true, "refunded max-runs job should be runnable again")
|
|
210
|
+
assert.equal(job.infrastructureFailureCount, 1)
|
|
211
|
+
assert.ok(fireCalls.some((call) => String(call[1]).includes("provider retry watchdog")), "watchdog must abort only the Loop-owned stuck turn")
|
|
212
|
+
assert.ok(schedules.some((call) => call[3] >= 5), "recovery must reschedule with backoff")
|
|
213
|
+
clearSessionActivity(sessionID)
|
|
214
|
+
sessionStatuses.delete(sessionID)
|
|
215
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log("network recovery + terminal continuation guard: ok")
|
|
@@ -158,17 +158,31 @@ try {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
{
|
|
161
|
-
const sessionID = "
|
|
161
|
+
const sessionID = "retry-remains-owned"
|
|
162
162
|
track(sessionID)
|
|
163
163
|
runtime.clearSessionStatus(sessionID)
|
|
164
164
|
activeRuns.set(sessionID, { jobId: "job-2", job: { id: "job-2" }, startedAt: 1_000 })
|
|
165
165
|
completion = "unknown"
|
|
166
|
+
const beforeLogs = logs.length
|
|
166
167
|
const client = { session: { status: async () => ({ data: { [sessionID]: { type: "retry" } } }) } }
|
|
167
|
-
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "
|
|
168
|
-
assert.
|
|
168
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "retry")
|
|
169
|
+
assert.equal(logs.length, beforeLogs, "unknown retry must not use stale-busy recovery")
|
|
169
170
|
activeRuns.delete(sessionID)
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
{
|
|
174
|
+
const sessionID = "retry-message-complete"
|
|
175
|
+
track(sessionID)
|
|
176
|
+
runtime.clearSessionStatus(sessionID)
|
|
177
|
+
activeRuns.set(sessionID, { jobId: "job-retry-complete", job: { id: "job-retry-complete", name: "retry-complete" }, startedAt: 1_000 })
|
|
178
|
+
completion = "completed"
|
|
179
|
+
const client = { session: { status: async () => ({ data: { [sessionID]: { type: "retry" } } }) } }
|
|
180
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle", "a genuinely completed current assistant turn may release stale retry")
|
|
181
|
+
assert.deepEqual(logs.at(-1), ["/repo", "status-message-complete-recovery", { sessionID, job: "retry-complete", startedAt: 1_000 }])
|
|
182
|
+
activeRuns.delete(sessionID)
|
|
183
|
+
completion = "unknown"
|
|
184
|
+
}
|
|
185
|
+
|
|
172
186
|
{
|
|
173
187
|
const sessionID = "fallback-fresh"
|
|
174
188
|
track(sessionID)
|
|
@@ -178,7 +192,7 @@ try {
|
|
|
178
192
|
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy")
|
|
179
193
|
activeRuns.set(sessionID, { jobId: "stale", job: {}, startedAt: clock - 60_000 })
|
|
180
194
|
runtime.clearSessionStatus(sessionID)
|
|
181
|
-
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "
|
|
195
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy", "status transport failure must fail closed even for an old active run")
|
|
182
196
|
activeRuns.delete(sessionID)
|
|
183
197
|
}
|
|
184
198
|
|