opencode-immune 1.0.6 → 1.0.7
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/dist/plugin.js +104 -87
- package/package.json +2 -2
package/dist/plugin.js
CHANGED
|
@@ -10,8 +10,8 @@ function createState(input) {
|
|
|
10
10
|
input,
|
|
11
11
|
recoveryContext: null,
|
|
12
12
|
managedUltraworkSessions: new Map(),
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
sessionRetryTimers: new Map(),
|
|
14
|
+
sessionErrorRetryCount: new Map(),
|
|
15
15
|
managedSessionsCachePath: (0, path_1.join)(input.directory, ".opencode", "state", "opencode-immune-managed-sessions.json"),
|
|
16
16
|
diagnosticsLogPath: (0, path_1.join)(input.directory, ".opencode", "state", "opencode-immune-debug.log"),
|
|
17
17
|
lastEditAttempt: null,
|
|
@@ -45,8 +45,8 @@ function pruneExpiredManagedSessions(state, now = Date.now()) {
|
|
|
45
45
|
if (now - record.updatedAt <= MANAGED_SESSION_TTL_MS) {
|
|
46
46
|
continue;
|
|
47
47
|
}
|
|
48
|
-
|
|
49
|
-
state.
|
|
48
|
+
cancelPendingSessionRetry(state, sessionID, "managed session TTL expired");
|
|
49
|
+
state.sessionErrorRetryCount.delete(sessionID);
|
|
50
50
|
state.managedUltraworkSessions.delete(sessionID);
|
|
51
51
|
removed++;
|
|
52
52
|
}
|
|
@@ -154,17 +154,17 @@ async function addManagedChildSession(state, sessionID, parentSessionID, timesta
|
|
|
154
154
|
});
|
|
155
155
|
await writeManagedSessionsCache(state);
|
|
156
156
|
}
|
|
157
|
-
function
|
|
158
|
-
const timer = state.
|
|
157
|
+
function cancelPendingSessionRetry(state, sessionID, reason) {
|
|
158
|
+
const timer = state.sessionRetryTimers.get(sessionID);
|
|
159
159
|
if (!timer)
|
|
160
160
|
return;
|
|
161
161
|
clearTimeout(timer);
|
|
162
|
-
state.
|
|
162
|
+
state.sessionRetryTimers.delete(sessionID);
|
|
163
163
|
console.log(`[opencode-immune] Cancelled pending retry for session ${sessionID}: ${reason}`);
|
|
164
164
|
}
|
|
165
165
|
async function removeManagedUltraworkSession(state, sessionID, reason) {
|
|
166
|
-
|
|
167
|
-
state.
|
|
166
|
+
cancelPendingSessionRetry(state, sessionID, reason);
|
|
167
|
+
state.sessionErrorRetryCount.delete(sessionID);
|
|
168
168
|
const existed = state.managedUltraworkSessions.delete(sessionID);
|
|
169
169
|
if (!existed)
|
|
170
170
|
return;
|
|
@@ -222,16 +222,28 @@ async function setSessionFallbackModel(state, sessionID, model) {
|
|
|
222
222
|
});
|
|
223
223
|
await writeManagedSessionsCache(state);
|
|
224
224
|
}
|
|
225
|
-
|
|
225
|
+
function getManagedSessionRetryContext(state, sessionID) {
|
|
226
226
|
const managedSession = state.managedUltraworkSessions.get(sessionID);
|
|
227
227
|
if (!managedSession)
|
|
228
|
-
return;
|
|
228
|
+
return null;
|
|
229
229
|
const retryAgent = managedSession.agent || ULTRAWORK_AGENT;
|
|
230
230
|
const fallbackModel = managedSession.fallbackModel;
|
|
231
231
|
const retryText = retryAgent === ULTRAWORK_AGENT
|
|
232
232
|
? "[SYSTEM: Previous API call failed with a transient error. Re-read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use the exact neutral prompt from your Step 5 table for the next router call. Do NOT analyze or evaluate file contents.]"
|
|
233
233
|
: `[SYSTEM: Previous API call failed with a transient error. Resume the current task in your current role as ${retryAgent}. Continue from the existing session state. Do not restart from scratch unless the current session state is missing.]`;
|
|
234
|
-
|
|
234
|
+
return {
|
|
235
|
+
managedSession,
|
|
236
|
+
retryAgent,
|
|
237
|
+
retryText,
|
|
238
|
+
fallbackModel,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function sendManagedSessionRetryPrompt(state, sessionID, reason, options = {}) {
|
|
242
|
+
const retryContext = getManagedSessionRetryContext(state, sessionID);
|
|
243
|
+
if (!retryContext)
|
|
244
|
+
return false;
|
|
245
|
+
const { managedSession, retryAgent, retryText, fallbackModel } = retryContext;
|
|
246
|
+
await writeDiagnosticLog(state, "session-retry:start", {
|
|
235
247
|
sessionID,
|
|
236
248
|
reason,
|
|
237
249
|
retryAgent,
|
|
@@ -239,17 +251,19 @@ async function forceRetryManagedSession(state, sessionID, reason) {
|
|
|
239
251
|
rootSessionID: managedSession.rootSessionID,
|
|
240
252
|
fallbackModel,
|
|
241
253
|
});
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
if (options.abortBeforePrompt) {
|
|
255
|
+
try {
|
|
256
|
+
await state.input.client.session.abort({
|
|
257
|
+
path: { id: sessionID },
|
|
258
|
+
});
|
|
259
|
+
await writeDiagnosticLog(state, "session-retry:abort-success", { sessionID });
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
await writeDiagnosticLog(state, "session-retry:abort-failed", {
|
|
263
|
+
sessionID,
|
|
264
|
+
error: err instanceof Error ? err.message : String(err),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
253
267
|
}
|
|
254
268
|
await state.input.client.session.promptAsync({
|
|
255
269
|
body: {
|
|
@@ -264,16 +278,49 @@ async function forceRetryManagedSession(state, sessionID, reason) {
|
|
|
264
278
|
},
|
|
265
279
|
path: { id: sessionID },
|
|
266
280
|
});
|
|
267
|
-
console.log(`[opencode-immune]
|
|
281
|
+
console.log(`[opencode-immune] Retry prompt sent to session ${sessionID} (${reason})` +
|
|
268
282
|
(fallbackModel
|
|
269
283
|
? ` using fallback model ${fallbackModel.providerID}/${fallbackModel.modelID}`
|
|
270
284
|
: ""));
|
|
271
|
-
await writeDiagnosticLog(state, "
|
|
285
|
+
await writeDiagnosticLog(state, "session-retry:prompt-sent", {
|
|
272
286
|
sessionID,
|
|
273
287
|
reason,
|
|
274
288
|
retryAgent,
|
|
275
289
|
fallbackModel,
|
|
276
290
|
});
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
function scheduleManagedSessionRetry(state, sessionID, options) {
|
|
294
|
+
if (!isManagedRootUltraworkSession(state, sessionID)) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
if (state.sessionRetryTimers.has(sessionID)) {
|
|
298
|
+
console.log(`[opencode-immune] Retry already pending for session ${sessionID}, skipping duplicate.`);
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
const attemptInfo = options.attemptLabel ? ` (${options.attemptLabel})` : "";
|
|
302
|
+
console.log(`[opencode-immune] Scheduling retry for session ${sessionID}${attemptInfo}. ` +
|
|
303
|
+
`Waiting ${options.delayMs / 1000}s before retry...`);
|
|
304
|
+
const timer = setTimeout(async () => {
|
|
305
|
+
state.sessionRetryTimers.delete(sessionID);
|
|
306
|
+
if (!isManagedRootUltraworkSession(state, sessionID)) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
await sendManagedSessionRetryPrompt(state, sessionID, options.reason, {
|
|
311
|
+
abortBeforePrompt: options.abortBeforePrompt,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
if (options.countAgainstBudget) {
|
|
316
|
+
state.sessionErrorRetryCount.set(sessionID, Math.max((state.sessionErrorRetryCount.get(sessionID) ?? 1) - 1, 0));
|
|
317
|
+
}
|
|
318
|
+
console.log(`[opencode-immune] Retry prompt failed for session ${sessionID}. ` +
|
|
319
|
+
`Will wait for the next retry signal.`);
|
|
320
|
+
}
|
|
321
|
+
}, options.delayMs);
|
|
322
|
+
state.sessionRetryTimers.set(sessionID, timer);
|
|
323
|
+
return true;
|
|
277
324
|
}
|
|
278
325
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
279
326
|
// UTILITY: ERROR BOUNDARY
|
|
@@ -713,68 +760,39 @@ function createEventHandler(state) {
|
|
|
713
760
|
const eventType = event.type ?? "unknown";
|
|
714
761
|
const info = event.properties?.info;
|
|
715
762
|
const sessionID = event.properties?.sessionID ?? info?.id;
|
|
716
|
-
// ── Auto-retry on retryable API error for managed ultrawork sessions ──
|
|
763
|
+
// ── Auto-retry on retryable API error for managed root ultrawork sessions ──
|
|
717
764
|
if (eventType === "session.error" && sessionID) {
|
|
718
765
|
const error = event.properties?.error;
|
|
719
|
-
if (!
|
|
766
|
+
if (!isManagedRootUltraworkSession(state, sessionID)) {
|
|
720
767
|
return;
|
|
721
768
|
}
|
|
722
769
|
if (!isRetryableApiError(error)) {
|
|
723
|
-
|
|
724
|
-
state.
|
|
770
|
+
cancelPendingSessionRetry(state, sessionID, "non-retryable or user-aborted error");
|
|
771
|
+
state.sessionErrorRetryCount.delete(sessionID);
|
|
725
772
|
return;
|
|
726
773
|
}
|
|
727
|
-
if (state.
|
|
774
|
+
if (state.sessionRetryTimers.has(sessionID)) {
|
|
728
775
|
console.log(`[opencode-immune] Retry already pending for session ${sessionID}, skipping duplicate.`);
|
|
729
776
|
return;
|
|
730
777
|
}
|
|
731
|
-
const count = state.
|
|
778
|
+
const count = state.sessionErrorRetryCount.get(sessionID) ?? 0;
|
|
732
779
|
if (count < MAX_RETRIES) {
|
|
733
780
|
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, count), MAX_DELAY_MS);
|
|
734
|
-
state.
|
|
781
|
+
state.sessionErrorRetryCount.set(sessionID, count + 1);
|
|
735
782
|
if (isRateLimitApiError(error)) {
|
|
736
783
|
await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
|
|
737
784
|
console.log(`[opencode-immune] Rate limit detected for session ${sessionID}. ` +
|
|
738
785
|
`Retry will use fallback model ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
|
|
739
786
|
}
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
state.retryTimers.delete(sessionID);
|
|
750
|
-
if (!isManagedUltraworkSession(state, sessionID)) {
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
try {
|
|
754
|
-
await state.input.client.session.promptAsync({
|
|
755
|
-
body: {
|
|
756
|
-
...(fallbackModel ? { model: fallbackModel } : {}),
|
|
757
|
-
agent: retryAgent,
|
|
758
|
-
parts: [
|
|
759
|
-
{
|
|
760
|
-
type: "text",
|
|
761
|
-
text: retryText,
|
|
762
|
-
},
|
|
763
|
-
],
|
|
764
|
-
},
|
|
765
|
-
path: { id: sessionID },
|
|
766
|
-
});
|
|
767
|
-
console.log(`[opencode-immune] Auto-retry message sent to session ${sessionID}` +
|
|
768
|
-
(fallbackModel
|
|
769
|
-
? ` using fallback model ${fallbackModel.providerID}/${fallbackModel.modelID}`
|
|
770
|
-
: ""));
|
|
771
|
-
}
|
|
772
|
-
catch (err) {
|
|
773
|
-
state.retryCount.set(sessionID, Math.max((state.retryCount.get(sessionID) ?? 1) - 1, 0));
|
|
774
|
-
console.log(`[opencode-immune] Auto-retry failed (still offline?). Will retry on next error event.`);
|
|
775
|
-
}
|
|
776
|
-
}, delay);
|
|
777
|
-
state.retryTimers.set(sessionID, timer);
|
|
787
|
+
const scheduled = scheduleManagedSessionRetry(state, sessionID, {
|
|
788
|
+
delayMs: delay,
|
|
789
|
+
reason: "session.error",
|
|
790
|
+
attemptLabel: `attempt ${count + 1}/${MAX_RETRIES}`,
|
|
791
|
+
countAgainstBudget: true,
|
|
792
|
+
});
|
|
793
|
+
if (!scheduled) {
|
|
794
|
+
state.sessionErrorRetryCount.set(sessionID, count);
|
|
795
|
+
}
|
|
778
796
|
}
|
|
779
797
|
else {
|
|
780
798
|
console.log(`[opencode-immune] Max retries (${MAX_RETRIES}) reached for session ${sessionID}. Not retrying.`);
|
|
@@ -782,8 +800,8 @@ function createEventHandler(state) {
|
|
|
782
800
|
}
|
|
783
801
|
// Reset retry counter on successful activity
|
|
784
802
|
if (eventType === "session.updated" && sessionID) {
|
|
785
|
-
|
|
786
|
-
state.
|
|
803
|
+
cancelPendingSessionRetry(state, sessionID, "session updated");
|
|
804
|
+
state.sessionErrorRetryCount.delete(sessionID);
|
|
787
805
|
if (markUltraworkSessionActive(state, sessionID)) {
|
|
788
806
|
await writeManagedSessionsCache(state);
|
|
789
807
|
}
|
|
@@ -833,28 +851,27 @@ function createMultiCycleHandler(state) {
|
|
|
833
851
|
messageContent = messageContent.trim();
|
|
834
852
|
if (!messageContent)
|
|
835
853
|
return;
|
|
836
|
-
|
|
837
|
-
|
|
854
|
+
const messageRole = output.message?.role;
|
|
855
|
+
if (!isManagedRootUltraworkSession(state, sessionID))
|
|
856
|
+
return;
|
|
857
|
+
const managedSession = getManagedSession(state, sessionID);
|
|
858
|
+
if (sessionID &&
|
|
859
|
+
messageRole === "assistant" &&
|
|
860
|
+
RATE_LIMIT_MESSAGE_PATTERN.test(messageContent)) {
|
|
838
861
|
if (managedSession && !managedSession.fallbackModel) {
|
|
839
862
|
await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
|
|
840
863
|
console.log(`[opencode-immune] Rate limit message detected in chat output for session ${sessionID}. ` +
|
|
841
864
|
`Fallback model pinned to ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
|
|
842
865
|
}
|
|
843
|
-
if (managedSession
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
console.error(`[opencode-immune] Forced retry after rate-limit message failed for session ${sessionID}:`, err);
|
|
851
|
-
}
|
|
852
|
-
}, 1_000);
|
|
853
|
-
state.retryTimers.set(sessionID, timer);
|
|
866
|
+
if (managedSession) {
|
|
867
|
+
scheduleManagedSessionRetry(state, sessionID, {
|
|
868
|
+
delayMs: 1_000,
|
|
869
|
+
reason: "rate-limit message fallback",
|
|
870
|
+
countAgainstBudget: false,
|
|
871
|
+
abortBeforePrompt: true,
|
|
872
|
+
});
|
|
854
873
|
}
|
|
855
874
|
}
|
|
856
|
-
if (!isManagedRootUltraworkSession(state, sessionID))
|
|
857
|
-
return;
|
|
858
875
|
// ── PRE_COMMIT: execute /commit ──
|
|
859
876
|
if (messageContent.includes(PRE_COMMIT_MARKER) && !state.commitPending) {
|
|
860
877
|
state.commitPending = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-immune",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./server": "./dist/plugin.js"
|
|
@@ -29,4 +29,4 @@
|
|
|
29
29
|
"retry"
|
|
30
30
|
],
|
|
31
31
|
"license": "MIT"
|
|
32
|
-
}
|
|
32
|
+
}
|