@bybrawe/opencode-loop 0.5.37 → 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 +16 -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/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 +140 -5
- package/src/server.js +6451 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/legacy-v1.js +5 -0
- 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
|
@@ -157,6 +157,36 @@ try {
|
|
|
157
157
|
completion = "unknown"
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
{
|
|
161
|
+
const sessionID = "concurrent-recovery-log-dedupe"
|
|
162
|
+
track(sessionID)
|
|
163
|
+
runtime.clearSessionStatus(sessionID)
|
|
164
|
+
activeRuns.set(sessionID, { jobId: "job-dedupe", job: { id: "job-dedupe", name: "dedupe" }, startedAt: 1_000 })
|
|
165
|
+
completion = "completed"
|
|
166
|
+
let releaseStatus
|
|
167
|
+
const gate = new Promise((resolve) => { releaseStatus = resolve })
|
|
168
|
+
let calls = 0
|
|
169
|
+
const client = {
|
|
170
|
+
session: {
|
|
171
|
+
status: async () => {
|
|
172
|
+
calls += 1
|
|
173
|
+
await gate
|
|
174
|
+
return { data: { [sessionID]: { type: "busy" } } }
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
const before = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
|
|
179
|
+
const pending = Array.from({ length: 20 }, () => runtime.sessionStatusType(client, sessionID, "/repo"))
|
|
180
|
+
await Promise.resolve()
|
|
181
|
+
assert.equal(calls, 20, "all concurrent status reads should be in flight before release")
|
|
182
|
+
releaseStatus()
|
|
183
|
+
assert.deepEqual(await Promise.all(pending), Array(20).fill("idle"))
|
|
184
|
+
const after = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
|
|
185
|
+
assert.equal(after - before, 1, "one active run must emit at most one copy of the same stale-recovery diagnostic")
|
|
186
|
+
activeRuns.delete(sessionID)
|
|
187
|
+
completion = "unknown"
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
{
|
|
161
191
|
const sessionID = "retry-remains-owned"
|
|
162
192
|
track(sessionID)
|
|
@@ -56,7 +56,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
|
|
|
56
56
|
await adapter.start()
|
|
57
57
|
const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" })
|
|
58
58
|
assert.deepEqual(result, { accepted: true })
|
|
59
|
-
assert.deepEqual(commands, [{
|
|
59
|
+
assert.deepEqual(commands, [{
|
|
60
|
+
sessionID: "ses_adapter",
|
|
61
|
+
name: "review",
|
|
62
|
+
command: "review",
|
|
63
|
+
arguments: "--quick",
|
|
64
|
+
text: "--quick",
|
|
65
|
+
}])
|
|
60
66
|
assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
|
|
61
67
|
await adapter.dispose()
|
|
62
68
|
}
|
|
@@ -103,7 +109,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
|
|
|
103
109
|
})
|
|
104
110
|
events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
|
|
105
111
|
await waitFor(() => commands.length === 1, "V2 command dispatch")
|
|
106
|
-
assert.deepEqual(commands[0], {
|
|
112
|
+
assert.deepEqual(commands[0], {
|
|
113
|
+
sessionID,
|
|
114
|
+
name: "review",
|
|
115
|
+
command: "review",
|
|
116
|
+
arguments: "--quick",
|
|
117
|
+
text: "--quick",
|
|
118
|
+
})
|
|
107
119
|
} finally {
|
|
108
120
|
await cleanup?.()
|
|
109
121
|
await rm(directory, { recursive: true, force: true })
|
|
@@ -3,7 +3,7 @@ import plugin, {
|
|
|
3
3
|
OPENCODE_LOOP_V2_COMMANDS,
|
|
4
4
|
OPENCODE_LOOP_V2_PLUGIN_ID,
|
|
5
5
|
} from "../src/source/opencode2/experimental.js"
|
|
6
|
-
import { parseOpenCode2LoopCommandText } from "../src/source/opencode2/commands.js"
|
|
6
|
+
import { parseOpenCode2LoopCommandText, registerOpenCode2LoopCommands } from "../src/source/opencode2/commands.js"
|
|
7
7
|
import {
|
|
8
8
|
OPENCODE_LOOP_V2_COMMAND_SOURCE,
|
|
9
9
|
OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
|
|
@@ -77,13 +77,43 @@ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
|
|
|
77
77
|
assert.equal(command?.description, definition.description)
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
let
|
|
80
|
+
let missingDraftMutationFailed = false
|
|
81
81
|
try {
|
|
82
82
|
await registered({})
|
|
83
83
|
} catch (error) {
|
|
84
|
-
|
|
84
|
+
missingDraftMutationFailed = /neither add\(\) nor update\(\)/.test(String(error))
|
|
85
85
|
}
|
|
86
|
-
assert.equal(
|
|
86
|
+
assert.equal(missingDraftMutationFailed, true)
|
|
87
|
+
|
|
88
|
+
const addedCommands = new Map()
|
|
89
|
+
const executedCommands = []
|
|
90
|
+
registerOpenCode2LoopCommands({
|
|
91
|
+
add(definition) {
|
|
92
|
+
addedCommands.set(definition.name, definition)
|
|
93
|
+
},
|
|
94
|
+
}, {
|
|
95
|
+
execute: async (event) => {
|
|
96
|
+
executedCommands.push(structuredClone(event))
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
assert.deepEqual([...addedCommands.keys()], Object.keys(OPENCODE_LOOP_V2_COMMANDS))
|
|
100
|
+
for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
|
|
101
|
+
const command = addedCommands.get(name)
|
|
102
|
+
assert.equal(command?.name, name)
|
|
103
|
+
assert.equal(command?.description, definition.description)
|
|
104
|
+
assert.equal(typeof command?.execute, "function")
|
|
105
|
+
}
|
|
106
|
+
await addedCommands.get("loop").execute({
|
|
107
|
+
sessionID: "ses_2_0_11",
|
|
108
|
+
prompt: { text: "devam et" },
|
|
109
|
+
delivery: "steer",
|
|
110
|
+
})
|
|
111
|
+
assert.deepEqual(executedCommands, [{
|
|
112
|
+
name: "loop",
|
|
113
|
+
sessionID: "ses_2_0_11",
|
|
114
|
+
arguments: "devam et",
|
|
115
|
+
delivery: "steer",
|
|
116
|
+
}])
|
|
87
117
|
|
|
88
118
|
assert.deepEqual(inspectOpenCode2Context(currentContext), {
|
|
89
119
|
commandTransform: true,
|
|
@@ -98,6 +128,7 @@ assert.deepEqual(inspectOpenCode2Context(currentContext), {
|
|
|
98
128
|
assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
|
|
99
129
|
list: true,
|
|
100
130
|
get: true,
|
|
131
|
+
add: false,
|
|
101
132
|
update: true,
|
|
102
133
|
remove: true,
|
|
103
134
|
})
|
|
@@ -164,6 +164,8 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
|
|
|
164
164
|
sessionID: "ses_v2",
|
|
165
165
|
noReply: true,
|
|
166
166
|
parts: [{ type: "text", text: "OpenCode loop status:\nNo active loop jobs." }],
|
|
167
|
+
text: "OpenCode loop status:\nNo active loop jobs.",
|
|
168
|
+
resume: false,
|
|
167
169
|
agent: "build",
|
|
168
170
|
model: { providerID: "canary", modelID: "canary" },
|
|
169
171
|
})
|
|
@@ -301,6 +303,79 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
|
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
305
|
|
|
306
|
+
{
|
|
307
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-2-0-11-command-add-"))
|
|
308
|
+
const sessionID = "ses_v2_2_0_11_add"
|
|
309
|
+
const events = controllableStream()
|
|
310
|
+
const prompts = []
|
|
311
|
+
const commands = new Map()
|
|
312
|
+
const ctx = {
|
|
313
|
+
app: { name: "opencode", version: "2.0.11", channel: "latest" },
|
|
314
|
+
location: { directory },
|
|
315
|
+
command: {
|
|
316
|
+
transform: async (callback) => {
|
|
317
|
+
await callback({
|
|
318
|
+
add(definition) {
|
|
319
|
+
commands.set(definition.name, definition)
|
|
320
|
+
},
|
|
321
|
+
})
|
|
322
|
+
return { dispose: async () => {} }
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
event: {
|
|
326
|
+
// OpenCode 2.0.11 exposes subscribe(options), so Function.length === 1
|
|
327
|
+
// even though it returns an AsyncIterable rather than accepting a callback.
|
|
328
|
+
subscribe(options) {
|
|
329
|
+
assert.equal(options, undefined)
|
|
330
|
+
return events.stream
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
session: {
|
|
334
|
+
prompt: async (input) => {
|
|
335
|
+
prompts.push(structuredClone(input))
|
|
336
|
+
return { accepted: true }
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(ctx)
|
|
342
|
+
try {
|
|
343
|
+
assert.ok(commands.has("loop"), "OpenCode 2.0.11 add() command editor must receive /loop")
|
|
344
|
+
await commands.get("loop").execute({
|
|
345
|
+
sessionID,
|
|
346
|
+
prompt: { text: "0s --max-runs 2 continue exact 2.0.11 command path" },
|
|
347
|
+
delivery: "steer",
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
351
|
+
await waitFor(async () => {
|
|
352
|
+
try { return JSON.parse(await readFile(stateFile, "utf8")).jobs?.length === 1 } catch { return false }
|
|
353
|
+
}, "OpenCode 2.0.11 direct command state creation")
|
|
354
|
+
|
|
355
|
+
await waitFor(() => prompts.length === 1, "OpenCode 2.0.11 direct command first local-kick dispatch")
|
|
356
|
+
assert.equal(prompts[0].sessionID, sessionID)
|
|
357
|
+
assert.match(prompts[0].text || "", /continue exact 2\.0\.11 command path/)
|
|
358
|
+
|
|
359
|
+
events.push({
|
|
360
|
+
type: "session.status",
|
|
361
|
+
location: { directory },
|
|
362
|
+
data: { sessionID, status: { type: "busy" } },
|
|
363
|
+
})
|
|
364
|
+
events.push({
|
|
365
|
+
type: "session.status",
|
|
366
|
+
location: { directory },
|
|
367
|
+
data: { sessionID, status: { type: "idle" } },
|
|
368
|
+
})
|
|
369
|
+
await waitFor(() => prompts.length === 2, "OpenCode 2.0.11 second dispatch from native idle status")
|
|
370
|
+
const state = JSON.parse(await readFile(stateFile, "utf8"))
|
|
371
|
+
assert.equal(state.jobs[0].runCount, 2)
|
|
372
|
+
assert.equal(state.jobs[0].enabled, false)
|
|
373
|
+
} finally {
|
|
374
|
+
await cleanup?.()
|
|
375
|
+
await rm(directory, { recursive: true, force: true })
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
304
379
|
{
|
|
305
380
|
let commandDisposals = 0
|
|
306
381
|
const result = await OpenCodeLoopV2ExperimentalPlugin.setup({
|
package/src/index.js
CHANGED
|
@@ -1086,6 +1086,19 @@ var NEXT_WORK_PATTERNS = [
|
|
|
1086
1086
|
/\bremaining (?:work|task|todo|step)/i,
|
|
1087
1087
|
/devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
|
|
1088
1088
|
];
|
|
1089
|
+
var WAITING_USER_PATTERNS = [
|
|
1090
|
+
/(?:^|\n)\s*onay bekleyenler\s*:/i,
|
|
1091
|
+
/(?:^|\n)\s*(?:kullan\u0131c\u0131|user) (?:onay\u0131|onayi|aksiyonu|i\u015Flemi|islemi|eri\u015Fimi|erisimi) (?:bekleniyor|gerekiyor|gerekli)\b/i,
|
|
1092
|
+
/\b(?:senin|sizin) (?:onay\u0131n|onay\u0131n\u0131z|onayiniz|aksiyonun|aksiyonunuz|eri\u015Fimin|erisimin) (?:bekleniyor|gerekiyor|gerekli)\b/i,
|
|
1093
|
+
/\b(?:onay|eri\u015Fim|erisim|yetki) olmadan (?:devam edemem|ilerleyemem|i\u015Flem yapamam|islem yapamam)\b/i,
|
|
1094
|
+
/(?:^|\n)\s*(?:waiting (?:for|on) (?:your|user) (?:approval|input|action|access|credentials?)|(?:approval|user action|access) (?:required|pending))\s*:?/i,
|
|
1095
|
+
/\b(?:requires?|needs?) (?:your|user) (?:approval|input|action|access|credentials?)\b/i,
|
|
1096
|
+
/\bblocked (?:pending|until|on) (?:your|user|approval|access|credentials?)\b/i
|
|
1097
|
+
];
|
|
1098
|
+
var WAITING_USER_NEGATIONS = [
|
|
1099
|
+
/\b(?:onay|eri\u015Fim|erisim|yetki) (?:gerekmiyor|gerekli de\u011Fil|gerekli degil|beklenmiyor)\b/i,
|
|
1100
|
+
/\bno (?:user )?(?:approval|input|action|access|credentials?) (?:is )?(?:needed|required|pending)\b/i
|
|
1101
|
+
];
|
|
1089
1102
|
function isContinuationShorthand(value) {
|
|
1090
1103
|
return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
|
|
1091
1104
|
}
|
|
@@ -1093,6 +1106,16 @@ function isCompletionBoundedContinuation(value) {
|
|
|
1093
1106
|
const text = String(value || "").trim();
|
|
1094
1107
|
return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
|
|
1095
1108
|
}
|
|
1109
|
+
function isWaitingUserReply(value) {
|
|
1110
|
+
const text = String(value || "").trim();
|
|
1111
|
+
if (!text)
|
|
1112
|
+
return false;
|
|
1113
|
+
if (WAITING_USER_NEGATIONS.some((pattern) => pattern.test(text)))
|
|
1114
|
+
return false;
|
|
1115
|
+
if (NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
|
|
1116
|
+
return false;
|
|
1117
|
+
return WAITING_USER_PATTERNS.some((pattern) => pattern.test(text));
|
|
1118
|
+
}
|
|
1096
1119
|
function isTerminalNoWorkReply(value) {
|
|
1097
1120
|
const text = String(value || "").trim();
|
|
1098
1121
|
if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
|
|
@@ -3048,7 +3071,11 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3048
3071
|
...completion === "completed" ? {} : { staleStatus: live.type }
|
|
3049
3072
|
};
|
|
3050
3073
|
const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
|
|
3051
|
-
|
|
3074
|
+
active.statusRecoveryLogEvents ??= new Set;
|
|
3075
|
+
if (!active.statusRecoveryLogEvents.has(recoveryEvent)) {
|
|
3076
|
+
active.statusRecoveryLogEvents.add(recoveryEvent);
|
|
3077
|
+
await appendLoopLog2(directory, recoveryEvent, logDetails);
|
|
3078
|
+
}
|
|
3052
3079
|
return "idle";
|
|
3053
3080
|
}
|
|
3054
3081
|
}
|
|
@@ -3280,8 +3307,10 @@ function messageText(message) {
|
|
|
3280
3307
|
return "";
|
|
3281
3308
|
}
|
|
3282
3309
|
async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
|
|
3283
|
-
|
|
3284
|
-
|
|
3310
|
+
const completionBounded = isCompletionBoundedContinuation(job?.action);
|
|
3311
|
+
const continuation = completionBounded || isContinuationShorthand(job?.action);
|
|
3312
|
+
if (job?.scheduleMode !== "idle" || !continuation) {
|
|
3313
|
+
return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false };
|
|
3285
3314
|
}
|
|
3286
3315
|
const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
|
|
3287
3316
|
if (!messages)
|
|
@@ -3299,11 +3328,31 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
|
|
|
3299
3328
|
return { job, terminal: false, pausedNow: false };
|
|
3300
3329
|
}
|
|
3301
3330
|
const text = messageText(tail);
|
|
3331
|
+
const waitingUser = isWaitingUserReply(text);
|
|
3332
|
+
if (waitingUser) {
|
|
3333
|
+
if (job.terminalNoWorkCount)
|
|
3334
|
+
job.terminalNoWorkCount = 0;
|
|
3335
|
+
job.waitingUserCount = (job.waitingUserCount || 0) + 1;
|
|
3336
|
+
job.lastWaitingUserAt = Date.now();
|
|
3337
|
+
job.lastWaitingUserSummary = text.slice(0, 1000);
|
|
3338
|
+
const threshold = Math.max(2, Number(options.waitingUserThreshold) || 2);
|
|
3339
|
+
const waitingUserPausedNow = job.waitingUserCount >= threshold && !job.paused;
|
|
3340
|
+
if (waitingUserPausedNow) {
|
|
3341
|
+
job.paused = true;
|
|
3342
|
+
job.lastFailureReason = "waiting_user";
|
|
3343
|
+
}
|
|
3344
|
+
return { job, terminal: false, pausedNow: false, waitingUser: true, waitingUserPausedNow, text };
|
|
3345
|
+
}
|
|
3346
|
+
if (job.waitingUserCount)
|
|
3347
|
+
job.waitingUserCount = 0;
|
|
3348
|
+
if (!completionBounded) {
|
|
3349
|
+
return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
|
|
3350
|
+
}
|
|
3302
3351
|
const terminal = isTerminalNoWorkReply(text);
|
|
3303
3352
|
if (!terminal) {
|
|
3304
3353
|
if (job.terminalNoWorkCount)
|
|
3305
3354
|
job.terminalNoWorkCount = 0;
|
|
3306
|
-
return { job, terminal: false, pausedNow: false, text };
|
|
3355
|
+
return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
|
|
3307
3356
|
}
|
|
3308
3357
|
job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
|
|
3309
3358
|
job.lastTerminalNoWorkAt = Date.now();
|
|
@@ -3314,7 +3363,7 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
|
|
|
3314
3363
|
job.paused = true;
|
|
3315
3364
|
job.lastFailureReason = "terminal_no_work";
|
|
3316
3365
|
}
|
|
3317
|
-
return { job, terminal: true, pausedNow, text };
|
|
3366
|
+
return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
|
|
3318
3367
|
}
|
|
3319
3368
|
|
|
3320
3369
|
// src/source/runtime/run-finalization.js
|
|
@@ -3408,6 +3457,16 @@ exit=` + postrun.code + `
|
|
|
3408
3457
|
}
|
|
3409
3458
|
const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
|
|
3410
3459
|
job = terminal.job;
|
|
3460
|
+
if (terminal.waitingUserPausedNow) {
|
|
3461
|
+
await appendLoopLog2(directory, "waiting-user", {
|
|
3462
|
+
sessionID,
|
|
3463
|
+
job: job.name || job.id,
|
|
3464
|
+
count: job.waitingUserCount,
|
|
3465
|
+
summary: String(terminal.text || "").slice(0, 1000)
|
|
3466
|
+
});
|
|
3467
|
+
await notifyJob2(directory, job, "waiting_user");
|
|
3468
|
+
await toast2(client, "Loop paused: autonomous continuation is waiting for explicit user approval, access, or action.", "warning");
|
|
3469
|
+
}
|
|
3411
3470
|
if (terminal.pausedNow) {
|
|
3412
3471
|
await appendLoopLog2(directory, "terminal-no-work", {
|
|
3413
3472
|
sessionID,
|
|
@@ -3615,6 +3674,25 @@ function requireFunction10(value, label) {
|
|
|
3615
3674
|
throw new TypeError(`createLoopExecutor requires ${label}`);
|
|
3616
3675
|
return value;
|
|
3617
3676
|
}
|
|
3677
|
+
function sessionErrorDetails(event, fallbackMessage) {
|
|
3678
|
+
if (event?.type !== "session.error")
|
|
3679
|
+
return;
|
|
3680
|
+
const sessionID = event?.properties?.sessionID;
|
|
3681
|
+
if (typeof sessionID !== "string" || !sessionID)
|
|
3682
|
+
return;
|
|
3683
|
+
const error = event?.properties?.error;
|
|
3684
|
+
const name = error && typeof error === "object" && typeof error.name === "string" ? error.name : "SessionError";
|
|
3685
|
+
const data = error && typeof error === "object" && error.data && typeof error.data === "object" ? error.data : undefined;
|
|
3686
|
+
const message = typeof data?.message === "string" && data.message.trim() ? data.message.trim() : fallbackMessage(error);
|
|
3687
|
+
return {
|
|
3688
|
+
sessionID,
|
|
3689
|
+
error,
|
|
3690
|
+
name,
|
|
3691
|
+
message,
|
|
3692
|
+
aborted: name === "MessageAbortedError",
|
|
3693
|
+
retryable: name === "APIError" && data?.isRetryable === true
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3618
3696
|
function createLoopExecutor(options = {}) {
|
|
3619
3697
|
const workspace = options.workspace || {};
|
|
3620
3698
|
const goalPolicy = options.goalPolicy || {};
|
|
@@ -3793,6 +3871,56 @@ function createLoopExecutor(options = {}) {
|
|
|
3793
3871
|
await scheduleDueWork(directory, client, sessionID, retryDelay);
|
|
3794
3872
|
return true;
|
|
3795
3873
|
}
|
|
3874
|
+
async function handleSessionError(directory, client, event) {
|
|
3875
|
+
const details = sessionErrorDetails(event, errorMessage);
|
|
3876
|
+
if (!details || details.aborted)
|
|
3877
|
+
return false;
|
|
3878
|
+
const active = activeRuns.get(details.sessionID);
|
|
3879
|
+
if (!active || active.compactionOnly)
|
|
3880
|
+
return false;
|
|
3881
|
+
const snapshot = active;
|
|
3882
|
+
clearActiveRun(details.sessionID);
|
|
3883
|
+
clearSessionStatus(details.sessionID);
|
|
3884
|
+
if (details.retryable) {
|
|
3885
|
+
const refunded = await persistInfrastructureRefund(directory, details.sessionID, snapshot, {
|
|
3886
|
+
reason: "session_error_retryable",
|
|
3887
|
+
error: details.message
|
|
3888
|
+
});
|
|
3889
|
+
await appendLoopLog2(directory, "provider-session-error-retry", {
|
|
3890
|
+
sessionID: details.sessionID,
|
|
3891
|
+
job: refunded.job?.name || snapshot.jobId,
|
|
3892
|
+
errorName: details.name,
|
|
3893
|
+
error: details.message,
|
|
3894
|
+
retryInMs: refunded.delayMs
|
|
3895
|
+
});
|
|
3896
|
+
await toast2(client, `Loop provider turn failed after OpenCode retries; the logical run was refunded and will retry with backoff: ${details.message}`, "warning");
|
|
3897
|
+
await scheduleDueWork(directory, client, details.sessionID, refunded.delayMs);
|
|
3898
|
+
return true;
|
|
3899
|
+
}
|
|
3900
|
+
const state = await readState2(directory, details.sessionID);
|
|
3901
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === snapshot.jobId);
|
|
3902
|
+
if (job) {
|
|
3903
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
3904
|
+
job.paused = true;
|
|
3905
|
+
job.lastFailureReason = "session_error";
|
|
3906
|
+
job.lastSessionErrorName = details.name;
|
|
3907
|
+
job.lastSessionError = details.message.slice(0, 4000);
|
|
3908
|
+
job.lastSessionErrorAt = now2();
|
|
3909
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3910
|
+
await writeState2(directory, details.sessionID, state);
|
|
3911
|
+
await notifyJob2(directory, job, "session_error");
|
|
3912
|
+
}
|
|
3913
|
+
await appendLoopLog2(directory, "session-error", {
|
|
3914
|
+
sessionID: details.sessionID,
|
|
3915
|
+
job: job?.name || snapshot.jobId,
|
|
3916
|
+
errorName: details.name,
|
|
3917
|
+
error: details.message,
|
|
3918
|
+
paused: Boolean(job?.paused)
|
|
3919
|
+
});
|
|
3920
|
+
await toast2(client, `Loop paused after a terminal OpenCode session error: ${details.message}`, "error");
|
|
3921
|
+
await scheduleDueWork(directory, client, details.sessionID);
|
|
3922
|
+
return true;
|
|
3923
|
+
}
|
|
3796
3924
|
async function recoverStuckProviderRetry(directory, client, sessionID) {
|
|
3797
3925
|
const active = activeRuns.get(sessionID);
|
|
3798
3926
|
if (!active || active.compactionOnly) {
|
|
@@ -4067,6 +4195,7 @@ function createLoopExecutor(options = {}) {
|
|
|
4067
4195
|
clearActiveRun,
|
|
4068
4196
|
disposeSession,
|
|
4069
4197
|
recoverActiveDispatchFailure,
|
|
4198
|
+
handleSessionError,
|
|
4070
4199
|
recoverStuckProviderRetry,
|
|
4071
4200
|
finalizeActiveRun,
|
|
4072
4201
|
fireAction,
|
|
@@ -4288,6 +4417,7 @@ var executorRuntime = createLoopExecutor({
|
|
|
4288
4417
|
});
|
|
4289
4418
|
var {
|
|
4290
4419
|
clearActiveRun,
|
|
4420
|
+
handleSessionError,
|
|
4291
4421
|
finalizeActiveRun,
|
|
4292
4422
|
maybeRunDueJobs: runDueJobs,
|
|
4293
4423
|
sessionIsIdle,
|
|
@@ -4517,6 +4647,11 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
4517
4647
|
const steering = await goalSteeringRuntime.handleEvent(directory, client, event);
|
|
4518
4648
|
if (steering?.handled && steering.sessionID)
|
|
4519
4649
|
rememberSession(directory, client, steering.sessionID);
|
|
4650
|
+
if (event.type === "session.error") {
|
|
4651
|
+
const handledError = await handleSessionError(directory, client, event);
|
|
4652
|
+
if (handledError && event?.properties?.sessionID)
|
|
4653
|
+
rememberSession(directory, client, event.properties.sessionID);
|
|
4654
|
+
}
|
|
4520
4655
|
const statusUpdate = updateSessionStatusFromEvent(event);
|
|
4521
4656
|
if (statusUpdate?.sessionID)
|
|
4522
4657
|
rememberSession(directory, client, statusUpdate.sessionID);
|