@bybrawe/opencode-loop 0.5.36 → 0.5.38
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 +26 -0
- package/README.md +1 -1
- package/package.json +7 -6
- package/scripts/comprehensive-test.mjs +7 -4
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/install-node.mjs +4 -2
- package/scripts/install-test.mjs +5 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode2-loop-canary.mjs +48 -7
- package/scripts/publish-workflow-test.mjs +5 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +30 -0
- package/scripts/smoke-test.mjs +21 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +257 -96
- package/src/server.js +6451 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/legacy-v1.js +34 -6
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +44 -8
- package/src/source/opencode2/events.js +11 -0
- package/src/source/opencode2/experimental.js +51 -7
- package/src/source/opencode2/prompt-runtime.js +36 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +5 -1
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.38
|
|
4
|
+
|
|
5
|
+
Long-session provider-error, waiting-user, and OpenCode 2.0.11 compatibility release.
|
|
6
|
+
|
|
7
|
+
- Route Loop-owned OpenCode `session.error` events into stable V1 run ownership instead of letting a terminal provider failure disappear into later idle/stale recovery.
|
|
8
|
+
- Pause fail-closed on non-retryable provider/auth/policy/unknown session errors, persisting the error name/message/timestamp so an unlimited idle loop cannot keep dispatching after a terminal host failure.
|
|
9
|
+
- Refund and exponentially back off retryable terminal `APIError` attempts without consuming logical `runCount`, `--max-runs`, or ordinary failure budget; keep `MessageAbortedError` on the existing user/session-abort path.
|
|
10
|
+
- Deduplicate identical stale/completed-status recovery diagnostics per active run, preventing concurrent status probes from amplifying one recovery event into hundreds of `loop.log` records.
|
|
11
|
+
- Keep ordinary `/loop devam et` intentionally unlimited, but pause after two consecutive current assistant replies explicitly say autonomous work is waiting for user approval, input, access, credentials, or another user action. A reply naming concrete autonomous next work clears the waiting-user streak.
|
|
12
|
+
- Add exact regressions for the reported Loto Diyarı free-tier provider error, the Turkish `Onay bekleyenler: commit + push, canlı migration, cihaz testi` steady-state, retryable provider recovery, abort classification, recovery-log deduplication, waiting-user UX, and generated-bundle parity.
|
|
13
|
+
- Harden Windows regression reliability with bounded temporary-directory cleanup retries and bounded waits for persisted stale-busy recovery, without changing production scheduler semantics.
|
|
14
|
+
- Add a dual local-plugin server entry that preserves the OpenCode 1.x `server` contract while exposing the OpenCode 2.x `{ id, setup }` contract required by OpenCode 2.0.11.
|
|
15
|
+
- Support the current OpenCode 2.0.11 command editor and session-command field shapes while retaining the older beta adapter path; normalize current native session execution/status events and select the official Promise event stream without relying on function arity or command-reload timing.
|
|
16
|
+
- Prove OpenCode 2.0.11 compatibility with an exact real-host gate: the local plugin is active, Loop commands register, a two-turn autonomous Loop reaches the deterministic provider exactly twice, control lifecycle commands mutate persisted state correctly, and `--max-runs` stops further turns.
|
|
17
|
+
- Verify the exact feature heads across Ubuntu/Windows CI, minimum/latest OpenCode plugin compatibility, real OpenCode Loop canaries, Dedicated Goal Coexistence, V2 Lifecycle Contract, OpenCode 2 Real Adapter, and the Single-file Bundle Gate.
|
|
18
|
+
|
|
19
|
+
## 0.5.37
|
|
20
|
+
|
|
21
|
+
Dedicated Goal tool-isolation release.
|
|
22
|
+
|
|
23
|
+
- Detect a persisted dedicated @bybrawe/opencode-goal Goal before any experimental `opencode_loop_goal_*` lifecycle tool can mutate Loop Goal state.
|
|
24
|
+
- Fail closed when a dedicated Goal owns the session and explicitly direct the model to `opencode_goal_progress`, `opencode_goal_wait_for_user`, `opencode_goal_blocked`, or `opencode_goal_complete` instead of returning a misleading experimental-Goal result.
|
|
25
|
+
- Mark the Loop lifecycle tools as experimental `/loop-goal`-only in their model-visible descriptions, preventing the exact blocker-tool collision reproduced by the long Loto Diyarı session.
|
|
26
|
+
- Keep modular source and the committed single-file stable bundle byte-for-byte synchronized with the Bun build output.
|
|
27
|
+
- Add coexistence smoke coverage for blocked, progress, and completion collisions; exact feature-head CI, Dedicated Goal Coexistence, and Single-file Bundle Gate pass across the required host/platform lanes.
|
|
28
|
+
|
|
3
29
|
## 0.5.36
|
|
4
30
|
|
|
5
31
|
Empty-turn fail-safe release.
|
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.38`.**
|
|
8
8
|
|
|
9
9
|
## Install or update
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.38",
|
|
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",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/index.js"
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./server": "./src/server.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src",
|
|
@@ -18,11 +19,11 @@
|
|
|
18
19
|
"CHANGELOG.md"
|
|
19
20
|
],
|
|
20
21
|
"scripts": {
|
|
21
|
-
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
|
|
22
|
+
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/server.js --outfile=src/server.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
|
|
22
23
|
"build:plugin:npm": "npm run build:plugin",
|
|
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/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",
|
|
24
|
+
"prepack": "node --check src/index.js && node --check src/server.js",
|
|
25
|
+
"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/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.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/server-entry-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/session-error-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",
|
|
26
|
+
"test": "node scripts/publish-workflow-test.mjs && node scripts/server-entry-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/session-error-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
27
|
"canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
|
|
27
28
|
"install:global": "node scripts/install-with-goals.mjs",
|
|
28
29
|
"pack:zip": "node scripts/make-zip.mjs"
|
|
@@ -165,7 +165,7 @@ async function createHarness(options = {}) {
|
|
|
165
165
|
},
|
|
166
166
|
async cleanup() {
|
|
167
167
|
await hooks.dispose?.()
|
|
168
|
-
await fs.rm(directory, { recursive: true, force: true })
|
|
168
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
169
169
|
},
|
|
170
170
|
}
|
|
171
171
|
}
|
|
@@ -486,8 +486,11 @@ async function testStaleBusyUsesCompletedAssistantTail() {
|
|
|
486
486
|
// and can reconcile the completed assistant tail instead of trusting cached busy.
|
|
487
487
|
await delay(1_700)
|
|
488
488
|
await h.command("loop-now", "stale-complete")
|
|
489
|
-
const state = await
|
|
490
|
-
|
|
489
|
+
const state = await waitForValue(async () => {
|
|
490
|
+
const candidate = await h.readState()
|
|
491
|
+
return candidate.jobs[0]?.lastFinishedAt > 0 ? candidate : undefined
|
|
492
|
+
}, 3_000)
|
|
493
|
+
assert.ok(state?.jobs[0]?.lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
|
|
491
494
|
assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history")
|
|
492
495
|
} finally {
|
|
493
496
|
await h.cleanup()
|
|
@@ -643,7 +646,7 @@ async function testInitializationDoesNotWaitForLocalApi() {
|
|
|
643
646
|
assert.equal(typeof hooks.event, "function")
|
|
644
647
|
await hooks.dispose?.()
|
|
645
648
|
} finally {
|
|
646
|
-
await fs.rm(directory, { recursive: true, force: true })
|
|
649
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
647
650
|
}
|
|
648
651
|
}
|
|
649
652
|
|
|
@@ -28,7 +28,12 @@ export default {
|
|
|
28
28
|
const sessionID = String(created?.id || created?.data?.id || created?.sessionID || "")
|
|
29
29
|
if (!sessionID) throw new Error("session.create returned no session id")
|
|
30
30
|
try {
|
|
31
|
-
await ctx.session.command({
|
|
31
|
+
await ctx.session.command({
|
|
32
|
+
sessionID,
|
|
33
|
+
name: COMMAND_SENTINEL,
|
|
34
|
+
command: COMMAND_SENTINEL,
|
|
35
|
+
text: "",
|
|
36
|
+
})
|
|
32
37
|
commandFieldProbe = {
|
|
33
38
|
matched: false,
|
|
34
39
|
sessionID,
|
package/scripts/install-node.mjs
CHANGED
|
@@ -18,7 +18,7 @@ const installerArgs = process.argv.slice(2)
|
|
|
18
18
|
const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
|
|
19
19
|
|
|
20
20
|
if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
|
|
21
|
-
console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update copies the Loop command files and local command agent, and keeps an existing npm package entry pinned to the exact version.\nUninstall removes Loop package registrations plus known local plugin/command/agent files while preserving project Loop state.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
|
|
21
|
+
console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update copies the dual OpenCode 1/2 Loop plugin plus command files and local command agent, and keeps an existing npm package entry pinned to the exact version.\nUninstall removes Loop package registrations plus known local plugin/command/agent files while preserving project Loop state.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
|
|
22
22
|
process.exit(0)
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -311,7 +311,9 @@ async function installOrUpdate() {
|
|
|
311
311
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
312
312
|
} else {
|
|
313
313
|
await ensureDependency()
|
|
314
|
-
|
|
314
|
+
// Copy the dual contract bundle: OpenCode 1.x consumes default.server,
|
|
315
|
+
// while OpenCode 2.x consumes default.setup from the same local file.
|
|
316
|
+
await copyFile(join(root, "src", "server.js"), join(pluginDir, "opencode-loop.ts"))
|
|
315
317
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
316
318
|
}
|
|
317
319
|
|
package/scripts/install-test.mjs
CHANGED
|
@@ -62,6 +62,11 @@ try {
|
|
|
62
62
|
const localResult = await runInstaller(local)
|
|
63
63
|
assert.equal(localResult.code, 0, localResult.stderr)
|
|
64
64
|
assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
|
|
65
|
+
assert.equal(
|
|
66
|
+
await fs.readFile(path.join(local, "plugins", "opencode-loop.ts"), "utf8"),
|
|
67
|
+
await fs.readFile(path.join(root, "src", "server.js"), "utf8"),
|
|
68
|
+
"local install must copy the dual OpenCode 1/2 server bundle",
|
|
69
|
+
)
|
|
65
70
|
assert.equal(await commandCount(local), packagedCommandCount)
|
|
66
71
|
assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
|
|
67
72
|
const localPackage = JSON.parse(await fs.readFile(path.join(local, "package.json"), "utf8"))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
|
-
import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../src/source/core/continuation.js"
|
|
2
|
+
import { isCompletionBoundedContinuation, isTerminalNoWorkReply, isWaitingUserReply } from "../src/source/core/continuation.js"
|
|
3
3
|
import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "../src/source/runtime/network-recovery.js"
|
|
4
4
|
import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
|
|
5
5
|
import { applyTerminalContinuationGuard } from "../src/source/runtime/terminal-guard.js"
|
|
@@ -90,6 +90,10 @@ assert.equal(isCompletionBoundedContinuation("devam et"), false)
|
|
|
90
90
|
assert.equal(isCompletionBoundedContinuation("devam et bitene kadar devam tamamen projeyi bitir"), true)
|
|
91
91
|
assert.equal(isTerminalNoWorkReply("Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata."), true)
|
|
92
92
|
assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build doğrulaması."), false)
|
|
93
|
+
assert.equal(isWaitingUserReply("Durum sabit.\n\nOnay bekleyenler: commit + push, canlı migration, cihaz testi."), true)
|
|
94
|
+
assert.equal(isWaitingUserReply("Waiting for your approval before I can push the release."), true)
|
|
95
|
+
assert.equal(isWaitingUserReply("Onay gerekmiyor; sıradaki iş build doğrulaması."), false)
|
|
96
|
+
assert.equal(isWaitingUserReply("Deployment needs your approval, but next step is local verification."), false)
|
|
93
97
|
|
|
94
98
|
{
|
|
95
99
|
const terminalMessages = [{
|
|
@@ -122,6 +126,39 @@ assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build do
|
|
|
122
126
|
const untouched = await applyTerminalContinuationGuard("/repo", client, "s", infinite)
|
|
123
127
|
assert.equal(untouched.terminal, false)
|
|
124
128
|
assert.equal(untouched.job.paused, false, "plain /loop devam et must remain intentionally infinite")
|
|
129
|
+
|
|
130
|
+
const waitingMessages = [{
|
|
131
|
+
info: { role: "assistant", time: { created: 2_200, completed: 2_300 } },
|
|
132
|
+
parts: [{ type: "text", text: "Durum sabit.\n\nOnay bekleyenler: commit + push, canlı migration, cihaz testi." }],
|
|
133
|
+
}]
|
|
134
|
+
const waitingClient = { session: { messages: async () => ({ data: waitingMessages }) } }
|
|
135
|
+
const waiting = {
|
|
136
|
+
id: "waiting",
|
|
137
|
+
action: "devam et",
|
|
138
|
+
scheduleMode: "idle",
|
|
139
|
+
lastRunAt: 1_500,
|
|
140
|
+
paused: false,
|
|
141
|
+
}
|
|
142
|
+
let waitingResult = await applyTerminalContinuationGuard("/repo", waitingClient, "s", waiting)
|
|
143
|
+
assert.equal(waitingResult.waitingUser, true)
|
|
144
|
+
assert.equal(waitingResult.waitingUserPausedNow, false)
|
|
145
|
+
assert.equal(waitingResult.job.waitingUserCount, 1)
|
|
146
|
+
assert.equal(waitingResult.job.paused, false)
|
|
147
|
+
waitingResult = await applyTerminalContinuationGuard("/repo", waitingClient, "s", waitingResult.job)
|
|
148
|
+
assert.equal(waitingResult.waitingUserPausedNow, true, "two explicit user-dependency replies should pause plain continuation")
|
|
149
|
+
assert.equal(waitingResult.job.paused, true)
|
|
150
|
+
assert.equal(waitingResult.job.lastFailureReason, "waiting_user")
|
|
151
|
+
|
|
152
|
+
const productiveMessages = [{
|
|
153
|
+
info: { role: "assistant", time: { created: 2_400, completed: 2_500 } },
|
|
154
|
+
parts: [{ type: "text", text: "Deployment needs your approval, but next step is local verification." }],
|
|
155
|
+
}]
|
|
156
|
+
const productiveClient = { session: { messages: async () => ({ data: productiveMessages }) } }
|
|
157
|
+
const productive = { ...waitingResult.job, paused: false, waitingUserCount: 1 }
|
|
158
|
+
const productiveResult = await applyTerminalContinuationGuard("/repo", productiveClient, "s", productive)
|
|
159
|
+
assert.equal(productiveResult.waitingUser, false)
|
|
160
|
+
assert.equal(productiveResult.job.waitingUserCount, 0, "a reply with autonomous next work must clear waiting-user streak")
|
|
161
|
+
assert.equal(productiveResult.job.paused, false)
|
|
125
162
|
}
|
|
126
163
|
|
|
127
164
|
{
|
|
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
|
|
2
2
|
import { createServer } from "node:http"
|
|
3
3
|
import net from "node:net"
|
|
4
4
|
import { execFileSync, spawn } from "node:child_process"
|
|
5
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
5
|
+
import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
6
6
|
import os from "node:os"
|
|
7
7
|
import path from "node:path"
|
|
8
8
|
import process from "node:process"
|
|
@@ -14,6 +14,8 @@ const EXPECTED_TURNS = 2
|
|
|
14
14
|
const EXPECTED_COMMANDS = ["loop", "loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear"]
|
|
15
15
|
const SERVER_USERNAME = "opencode"
|
|
16
16
|
const SERVER_PASSWORD = "opencode-loop-v2-canary"
|
|
17
|
+
const OPENCODE_BINARY = process.env.OPENCODE2_BINARY || "opencode2"
|
|
18
|
+
const CURRENT_COMMAND_FIELDS = process.env.OPENCODE2_CURRENT_COMMAND_FIELDS === "1"
|
|
17
19
|
|
|
18
20
|
function appendLog(current, chunk, limit = 100_000) {
|
|
19
21
|
return (current + String(chunk)).slice(-limit)
|
|
@@ -161,13 +163,36 @@ function startProvider() {
|
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
function bridgePluginSource() {
|
|
164
|
-
return `import { writeFile } from "node:fs/promises"
|
|
166
|
+
return `import { appendFile, writeFile } from "node:fs/promises"
|
|
165
167
|
|
|
166
168
|
export default {
|
|
167
169
|
id: "bybrawe.opencode-loop.v2.loop-canary-bridge",
|
|
168
170
|
async setup(ctx) {
|
|
169
171
|
const module = await import(process.env.OPENCODE_LOOP_V2_PLUGIN_URL)
|
|
170
|
-
const
|
|
172
|
+
const traceFile = process.env.OPENCODE_LOOP_V2_EVENT_TRACE
|
|
173
|
+
const pluginTraceFile = process.env.OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE
|
|
174
|
+
const pluginContext = typeof ctx?.event?.subscribe === "function"
|
|
175
|
+
? {
|
|
176
|
+
...ctx,
|
|
177
|
+
event: {
|
|
178
|
+
...ctx.event,
|
|
179
|
+
subscribe(options) {
|
|
180
|
+
const source = ctx.event.subscribe(options)
|
|
181
|
+
return {
|
|
182
|
+
async *[Symbol.asyncIterator]() {
|
|
183
|
+
for await (const event of source) {
|
|
184
|
+
const line = JSON.stringify(event) + "\\n"
|
|
185
|
+
if (traceFile) await appendFile(traceFile, line, "utf8")
|
|
186
|
+
if (pluginTraceFile) await appendFile(pluginTraceFile, line, "utf8")
|
|
187
|
+
yield event
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
: ctx
|
|
195
|
+
const cleanup = await module.default.setup(pluginContext)
|
|
171
196
|
await writeFile(process.env.OPENCODE_LOOP_V2_MARKER, JSON.stringify({ activated: true }, null, 2), "utf8")
|
|
172
197
|
return async () => {
|
|
173
198
|
if (typeof cleanup === "function") await cleanup()
|
|
@@ -187,8 +212,10 @@ async function main() {
|
|
|
187
212
|
|
|
188
213
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
|
|
189
214
|
const home = path.join(workspace, ".home")
|
|
190
|
-
const pluginDir = path.join(
|
|
215
|
+
const pluginDir = path.join(home, ".config", "opencode", "plugins")
|
|
191
216
|
const marker = path.join(workspace, "v2-real-adapter-marker.json")
|
|
217
|
+
const eventTrace = path.join(workspace, "v2-native-events.jsonl")
|
|
218
|
+
const pluginEventTrace = path.join(workspace, "v2-plugin-events.jsonl")
|
|
192
219
|
const provider = startProvider()
|
|
193
220
|
const providerPort = await provider.listen()
|
|
194
221
|
let server
|
|
@@ -241,6 +268,8 @@ async function main() {
|
|
|
241
268
|
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
242
269
|
OPENCODE_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", "source", "opencode2", "experimental.js")).href,
|
|
243
270
|
OPENCODE_LOOP_V2_MARKER: marker,
|
|
271
|
+
OPENCODE_LOOP_V2_EVENT_TRACE: eventTrace,
|
|
272
|
+
OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE: pluginEventTrace,
|
|
244
273
|
OPENCODE_SERVER_USERNAME: SERVER_USERNAME,
|
|
245
274
|
OPENCODE_SERVER_PASSWORD: SERVER_PASSWORD,
|
|
246
275
|
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
@@ -253,6 +282,10 @@ async function main() {
|
|
|
253
282
|
if (sessionID) {
|
|
254
283
|
try { state = await readFile(path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`), "utf8") } catch {}
|
|
255
284
|
}
|
|
285
|
+
let nativeEvents = "missing"
|
|
286
|
+
let pluginEvents = "missing"
|
|
287
|
+
try { nativeEvents = await readFile(eventTrace, "utf8") } catch {}
|
|
288
|
+
try { pluginEvents = await readFile(pluginEventTrace, "utf8") } catch {}
|
|
256
289
|
return [
|
|
257
290
|
`apiPrefix=${String(apiPrefix)}`,
|
|
258
291
|
`commands=${JSON.stringify([...latestCommands])}`,
|
|
@@ -260,14 +293,18 @@ async function main() {
|
|
|
260
293
|
`sessionID=${sessionID || "none"}`,
|
|
261
294
|
`commandError=${String(commandError ?? "none")}`,
|
|
262
295
|
`state=${state}`,
|
|
296
|
+
`binary=${OPENCODE_BINARY}`,
|
|
297
|
+
`currentCommandFields=${CURRENT_COMMAND_FIELDS}`,
|
|
263
298
|
`serverExit=${server?.exitCode}`,
|
|
299
|
+
`nativeEvents=${nativeEvents.slice(-40_000)}`,
|
|
300
|
+
`pluginEvents=${pluginEvents.slice(-40_000)}`,
|
|
264
301
|
`serverLog=${serverLog}`,
|
|
265
302
|
].join("\n")
|
|
266
303
|
}
|
|
267
304
|
|
|
268
305
|
try {
|
|
269
306
|
const port = await reservePort()
|
|
270
|
-
server = spawn(
|
|
307
|
+
server = spawn(OPENCODE_BINARY, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
|
|
271
308
|
cwd: workspace,
|
|
272
309
|
env,
|
|
273
310
|
windowsHide: true,
|
|
@@ -336,9 +373,13 @@ async function main() {
|
|
|
336
373
|
sessionID = String(session?.id ?? "")
|
|
337
374
|
assert.ok(sessionID, `OpenCode 2 did not create a session: ${createdResponse.text}\n${await diagnostics()}`)
|
|
338
375
|
|
|
376
|
+
const commandBody = (command, argumentsText = "") => CURRENT_COMMAND_FIELDS
|
|
377
|
+
? { name: command, text: argumentsText }
|
|
378
|
+
: { command, arguments: argumentsText }
|
|
379
|
+
|
|
339
380
|
const commandPromise = request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
|
|
340
381
|
method: "POST",
|
|
341
|
-
body: JSON.stringify(
|
|
382
|
+
body: JSON.stringify(commandBody("loop", `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}`)),
|
|
342
383
|
}, 120_000).catch((error) => {
|
|
343
384
|
commandError = error
|
|
344
385
|
return null
|
|
@@ -371,7 +412,7 @@ async function main() {
|
|
|
371
412
|
const sendControl = async (command, argumentsText = "") => {
|
|
372
413
|
const response = await request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
|
|
373
414
|
method: "POST",
|
|
374
|
-
body: JSON.stringify(
|
|
415
|
+
body: JSON.stringify(commandBody(command, argumentsText)),
|
|
375
416
|
}, 120_000)
|
|
376
417
|
if (!response.ok) throw new Error(`${command} failed: HTTP ${response.status} ${response.text}\n${await diagnostics()}`)
|
|
377
418
|
return response
|
|
@@ -14,6 +14,11 @@ assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/, "release tag must target the
|
|
|
14
14
|
assert.match(workflow, /group:\s*npm-release/, "npm publication must be serialized")
|
|
15
15
|
assert.match(workflow, /cancel-in-progress:\s*false/, "a newer release must not cancel an in-flight publication")
|
|
16
16
|
|
|
17
|
+
assert.match(workflow, /Decide whether npm publish is needed/, "release reruns must detect an already-published immutable version")
|
|
18
|
+
assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/, "npm publish must be idempotently gated")
|
|
19
|
+
assert.match(workflow, /for attempt in \{1\.\.60\}/, "registry verification must allow bounded multi-minute propagation")
|
|
20
|
+
assert.match(workflow, /--registry=https:\/\/registry\.npmjs\.org/, "registry checks must target the authoritative npm registry")
|
|
21
|
+
|
|
17
22
|
const publishIndex = workflow.indexOf("npm publish --access public")
|
|
18
23
|
const verifyIndex = workflow.indexOf("Verify published version is readable from npm")
|
|
19
24
|
const tagIndex = workflow.indexOf("Create tag and GitHub release")
|
|
@@ -98,6 +98,39 @@ assert.equal(failedPostrun.failureCount, 1)
|
|
|
98
98
|
assert.equal(failedPostrun.paused, true)
|
|
99
99
|
assert.equal(notifications.at(-1)[2], "postrun_failed")
|
|
100
100
|
|
|
101
|
+
const waitingNotifications = []
|
|
102
|
+
const waitingLogs = []
|
|
103
|
+
const waitingToasts = []
|
|
104
|
+
const waitingRuntime = createRunFinalizationRuntime({
|
|
105
|
+
runGoalChecks: async (_directory, _sessionID, job) => job,
|
|
106
|
+
applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
|
|
107
|
+
createCheckpoint: async () => {},
|
|
108
|
+
scheduleDueWork: async () => {},
|
|
109
|
+
writeState: async () => {},
|
|
110
|
+
appendLoopLog: async (...args) => { waitingLogs.push(args) },
|
|
111
|
+
notifyJob: async (...args) => { waitingNotifications.push(args) },
|
|
112
|
+
toast: async (...args) => { waitingToasts.push(args) },
|
|
113
|
+
applyTerminalContinuationGuard: async (_directory, _client, _sessionID, job) => ({
|
|
114
|
+
job: {
|
|
115
|
+
...job,
|
|
116
|
+
paused: true,
|
|
117
|
+
waitingUserCount: 2,
|
|
118
|
+
lastFailureReason: "waiting_user",
|
|
119
|
+
},
|
|
120
|
+
terminal: false,
|
|
121
|
+
pausedNow: false,
|
|
122
|
+
waitingUser: true,
|
|
123
|
+
waitingUserPausedNow: true,
|
|
124
|
+
text: "Onay bekleyenler: commit + push, canlı migration, cihaz testi.",
|
|
125
|
+
}),
|
|
126
|
+
})
|
|
127
|
+
const waitingJob = { id: "waiting-user", name: "default", enabled: true, paused: false }
|
|
128
|
+
await waitingRuntime.finalizeJob("/repo", {}, "session-waiting", { jobs: [waitingJob] }, waitingJob, { id: "before-waiting" })
|
|
129
|
+
assert.equal(waitingNotifications.at(-1)[2], "waiting_user")
|
|
130
|
+
assert.equal(waitingLogs.at(-1)[1], "waiting-user")
|
|
131
|
+
assert.match(waitingToasts.at(-1)[1], /waiting for explicit user approval/)
|
|
132
|
+
assert.equal(waitingToasts.at(-1)[2], "warning")
|
|
133
|
+
|
|
101
134
|
const goal = {
|
|
102
135
|
id: "goal-job",
|
|
103
136
|
kind: "goal",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import plugin, {
|
|
3
|
+
OPENCODE_LOOP_PLUGIN_ID,
|
|
4
|
+
OpenCodeLoopPlugin,
|
|
5
|
+
OpenCodeLoopV2ExperimentalPlugin,
|
|
6
|
+
} from "../src/source/server.js"
|
|
7
|
+
|
|
8
|
+
assert.equal(plugin.id, OPENCODE_LOOP_PLUGIN_ID)
|
|
9
|
+
assert.equal(plugin.id, "@bybrawe/opencode-loop")
|
|
10
|
+
assert.equal(typeof plugin.server, "function", "OpenCode 1.x requires default.server()")
|
|
11
|
+
assert.equal(typeof plugin.setup, "function", "OpenCode 2.x requires default.setup()")
|
|
12
|
+
assert.strictEqual(plugin.server, OpenCodeLoopPlugin)
|
|
13
|
+
assert.equal(typeof OpenCodeLoopV2ExperimentalPlugin.setup, "function")
|
|
14
|
+
|
|
15
|
+
console.log("dual OpenCode 1/2 server entry contract passed")
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createLoopExecutor } from "../src/source/runtime/executor.js"
|
|
3
|
+
import { clearSessionActivity } from "../src/source/runtime/session-activity.js"
|
|
4
|
+
|
|
5
|
+
function clone(value) {
|
|
6
|
+
return JSON.parse(JSON.stringify(value))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let clock = 1_000_000
|
|
10
|
+
const directory = "/repo"
|
|
11
|
+
const states = new Map()
|
|
12
|
+
const logs = []
|
|
13
|
+
const toasts = []
|
|
14
|
+
const schedules = []
|
|
15
|
+
const notifications = []
|
|
16
|
+
|
|
17
|
+
function stateKey(sessionID) {
|
|
18
|
+
return `${directory}:${sessionID}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setJob(sessionID, overrides = {}) {
|
|
22
|
+
states.set(stateKey(sessionID), {
|
|
23
|
+
jobs: [{
|
|
24
|
+
id: `${sessionID}-job`,
|
|
25
|
+
name: `${sessionID}-job`,
|
|
26
|
+
action: "devam et",
|
|
27
|
+
enabled: true,
|
|
28
|
+
paused: false,
|
|
29
|
+
intervalMs: 0,
|
|
30
|
+
runCount: 0,
|
|
31
|
+
maxRuns: 0,
|
|
32
|
+
maxRuntimeMs: 0,
|
|
33
|
+
timeoutMs: 0,
|
|
34
|
+
noOverlap: true,
|
|
35
|
+
...overrides,
|
|
36
|
+
}],
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const executor = createLoopExecutor({
|
|
41
|
+
workspace: {
|
|
42
|
+
buildPrompt: async (_directory, job) => job.action,
|
|
43
|
+
ensureBranch: async (_directory, job) => job,
|
|
44
|
+
watchChanged: async () => false,
|
|
45
|
+
untilReached: async () => false,
|
|
46
|
+
createCheckpoint: async () => {},
|
|
47
|
+
},
|
|
48
|
+
goalPolicy: {
|
|
49
|
+
runGoalChecks: async (_directory, _sessionID, job) => job,
|
|
50
|
+
applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
|
|
51
|
+
},
|
|
52
|
+
scheduler: {
|
|
53
|
+
rememberSession: () => {},
|
|
54
|
+
scheduleDueWork: async (...args) => schedules.push(args),
|
|
55
|
+
},
|
|
56
|
+
now: () => clock,
|
|
57
|
+
readState: async (_directory, sessionID) => clone(states.get(stateKey(sessionID)) || { jobs: [] }),
|
|
58
|
+
writeState: async (_directory, sessionID, state) => states.set(stateKey(sessionID), clone(state)),
|
|
59
|
+
appendLoopLog: async (...args) => logs.push(args),
|
|
60
|
+
notifyJob: async (...args) => notifications.push(args),
|
|
61
|
+
toast: async (...args) => toasts.push(args),
|
|
62
|
+
runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),
|
|
63
|
+
fireSdk: async () => ({ data: {} }),
|
|
64
|
+
sdkCall: async () => ({}),
|
|
65
|
+
normalizedModelRef: () => undefined,
|
|
66
|
+
compactTuiCommandName: () => undefined,
|
|
67
|
+
guardLoopOwnedUserMessage: () => {},
|
|
68
|
+
dangerousShell: () => false,
|
|
69
|
+
activeRunCompletionFromMessages: async () => "unknown",
|
|
70
|
+
busyRetryMs: 5_000,
|
|
71
|
+
networkRetryMaxMs: 60_000,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const client = {
|
|
75
|
+
session: {
|
|
76
|
+
status: async () => ({ data: {} }),
|
|
77
|
+
prompt: async () => ({ data: {} }),
|
|
78
|
+
abort: async () => ({ data: {} }),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
const sessionID = "policy-error"
|
|
84
|
+
setJob(sessionID)
|
|
85
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
86
|
+
assert.ok(executor.getActiveRun(sessionID))
|
|
87
|
+
assert.equal(states.get(stateKey(sessionID)).jobs[0].runCount, 1)
|
|
88
|
+
|
|
89
|
+
const handled = await executor.handleSessionError(directory, client, {
|
|
90
|
+
type: "session.error",
|
|
91
|
+
properties: {
|
|
92
|
+
sessionID,
|
|
93
|
+
error: {
|
|
94
|
+
name: "APIError",
|
|
95
|
+
data: {
|
|
96
|
+
message: "OpenCode's free tier can only be used from within OpenCode",
|
|
97
|
+
statusCode: 403,
|
|
98
|
+
isRetryable: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
assert.equal(handled, true)
|
|
105
|
+
assert.equal(executor.getActiveRun(sessionID), undefined)
|
|
106
|
+
const job = states.get(stateKey(sessionID)).jobs[0]
|
|
107
|
+
assert.equal(job.paused, true, "terminal provider policy error must pause an infinite idle loop")
|
|
108
|
+
assert.equal(job.failureCount, 1)
|
|
109
|
+
assert.equal(job.lastFailureReason, "session_error")
|
|
110
|
+
assert.equal(job.lastSessionErrorName, "APIError")
|
|
111
|
+
assert.equal(job.lastSessionError, "OpenCode's free tier can only be used from within OpenCode")
|
|
112
|
+
assert.equal(job.lastSessionErrorAt, clock)
|
|
113
|
+
assert.ok(notifications.some((call) => call[1]?.id === job.id && call[2] === "session_error"))
|
|
114
|
+
assert.ok(logs.some((call) => call[1] === "session-error" && call[2]?.sessionID === sessionID && call[2]?.paused === true))
|
|
115
|
+
assert.ok(toasts.some((call) => String(call[1]).includes("Loop paused after a terminal OpenCode session error")))
|
|
116
|
+
|
|
117
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
118
|
+
assert.equal(states.get(stateKey(sessionID)).jobs[0].runCount, 1, "paused terminal-error job must not immediately dispatch another run")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
const sessionID = "retryable-provider-error"
|
|
123
|
+
setJob(sessionID, { maxRuns: 1 })
|
|
124
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
125
|
+
let job = states.get(stateKey(sessionID)).jobs[0]
|
|
126
|
+
assert.equal(job.runCount, 1)
|
|
127
|
+
assert.equal(job.enabled, false, "max-runs may disable while the provider turn is in flight")
|
|
128
|
+
|
|
129
|
+
clock += 10
|
|
130
|
+
const handled = await executor.handleSessionError(directory, client, {
|
|
131
|
+
type: "session.error",
|
|
132
|
+
properties: {
|
|
133
|
+
sessionID,
|
|
134
|
+
error: {
|
|
135
|
+
name: "APIError",
|
|
136
|
+
data: {
|
|
137
|
+
message: "provider temporarily unavailable",
|
|
138
|
+
statusCode: 503,
|
|
139
|
+
isRetryable: true,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
assert.equal(handled, true)
|
|
146
|
+
assert.equal(executor.getActiveRun(sessionID), undefined)
|
|
147
|
+
job = states.get(stateKey(sessionID)).jobs[0]
|
|
148
|
+
assert.equal(job.runCount, 0, "retryable terminal provider error must refund the logical run")
|
|
149
|
+
assert.equal(job.enabled, true, "refunded max-runs disable must be rolled back")
|
|
150
|
+
assert.equal(job.paused, false)
|
|
151
|
+
assert.equal(job.failureCount || 0, 0, "retryable provider outage must not consume ordinary failure budget")
|
|
152
|
+
assert.equal(job.infrastructureFailureCount, 1)
|
|
153
|
+
assert.equal(job.lastInfrastructureFailure, "session_error_retryable")
|
|
154
|
+
assert.match(job.lastInfrastructureError, /provider temporarily unavailable/)
|
|
155
|
+
assert.ok(logs.some((call) => call[1] === "provider-session-error-retry" && call[2]?.sessionID === sessionID))
|
|
156
|
+
assert.ok(schedules.some((call) => call[2] === sessionID && call[3] >= 5_000))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
const sessionID = "user-abort"
|
|
161
|
+
setJob(sessionID)
|
|
162
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
163
|
+
const active = executor.getActiveRun(sessionID)
|
|
164
|
+
assert.ok(active)
|
|
165
|
+
|
|
166
|
+
const handled = await executor.handleSessionError(directory, client, {
|
|
167
|
+
type: "session.error",
|
|
168
|
+
properties: {
|
|
169
|
+
sessionID,
|
|
170
|
+
error: {
|
|
171
|
+
name: "MessageAbortedError",
|
|
172
|
+
data: { message: "The operation was aborted." },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
assert.equal(handled, false, "user/session abort must keep existing abort/finalization semantics")
|
|
178
|
+
assert.strictEqual(executor.getActiveRun(sessionID), active)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const sessionID of ["policy-error", "retryable-provider-error", "user-abort"]) {
|
|
182
|
+
executor.disposeSession(sessionID)
|
|
183
|
+
clearSessionActivity(sessionID)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log("session error recovery tests passed")
|