opencode-immune 1.0.5 → 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 +129 -74
- package/package.json +2 -2
package/dist/plugin.js
CHANGED
|
@@ -10,9 +10,10 @@ 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
|
+
diagnosticsLogPath: (0, path_1.join)(input.directory, ".opencode", "state", "opencode-immune-debug.log"),
|
|
16
17
|
lastEditAttempt: null,
|
|
17
18
|
toolCallCount: 0,
|
|
18
19
|
todoWriteUsed: false,
|
|
@@ -44,8 +45,8 @@ function pruneExpiredManagedSessions(state, now = Date.now()) {
|
|
|
44
45
|
if (now - record.updatedAt <= MANAGED_SESSION_TTL_MS) {
|
|
45
46
|
continue;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
state.
|
|
48
|
+
cancelPendingSessionRetry(state, sessionID, "managed session TTL expired");
|
|
49
|
+
state.sessionErrorRetryCount.delete(sessionID);
|
|
49
50
|
state.managedUltraworkSessions.delete(sessionID);
|
|
50
51
|
removed++;
|
|
51
52
|
}
|
|
@@ -66,6 +67,17 @@ async function writeManagedSessionsCache(state) {
|
|
|
66
67
|
console.log(`[opencode-immune] Pruned ${removed} expired managed ultrawork session(s) while writing cache.`);
|
|
67
68
|
}
|
|
68
69
|
}
|
|
70
|
+
async function writeDiagnosticLog(state, event, data = {}) {
|
|
71
|
+
try {
|
|
72
|
+
const cacheDir = (0, path_1.join)(state.input.directory, ".opencode", "state");
|
|
73
|
+
await (0, promises_1.mkdir)(cacheDir, { recursive: true });
|
|
74
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), event, ...data });
|
|
75
|
+
await (0, promises_1.appendFile)(state.diagnosticsLogPath, `${line}\n`, "utf-8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// diagnostics must never affect runtime behavior
|
|
79
|
+
}
|
|
80
|
+
}
|
|
69
81
|
async function loadManagedSessionsCache(state) {
|
|
70
82
|
try {
|
|
71
83
|
const raw = await (0, promises_1.readFile)(state.managedSessionsCachePath, "utf-8");
|
|
@@ -142,17 +154,17 @@ async function addManagedChildSession(state, sessionID, parentSessionID, timesta
|
|
|
142
154
|
});
|
|
143
155
|
await writeManagedSessionsCache(state);
|
|
144
156
|
}
|
|
145
|
-
function
|
|
146
|
-
const timer = state.
|
|
157
|
+
function cancelPendingSessionRetry(state, sessionID, reason) {
|
|
158
|
+
const timer = state.sessionRetryTimers.get(sessionID);
|
|
147
159
|
if (!timer)
|
|
148
160
|
return;
|
|
149
161
|
clearTimeout(timer);
|
|
150
|
-
state.
|
|
162
|
+
state.sessionRetryTimers.delete(sessionID);
|
|
151
163
|
console.log(`[opencode-immune] Cancelled pending retry for session ${sessionID}: ${reason}`);
|
|
152
164
|
}
|
|
153
165
|
async function removeManagedUltraworkSession(state, sessionID, reason) {
|
|
154
|
-
|
|
155
|
-
state.
|
|
166
|
+
cancelPendingSessionRetry(state, sessionID, reason);
|
|
167
|
+
state.sessionErrorRetryCount.delete(sessionID);
|
|
156
168
|
const existed = state.managedUltraworkSessions.delete(sessionID);
|
|
157
169
|
if (!existed)
|
|
158
170
|
return;
|
|
@@ -210,15 +222,49 @@ async function setSessionFallbackModel(state, sessionID, model) {
|
|
|
210
222
|
});
|
|
211
223
|
await writeManagedSessionsCache(state);
|
|
212
224
|
}
|
|
213
|
-
|
|
225
|
+
function getManagedSessionRetryContext(state, sessionID) {
|
|
214
226
|
const managedSession = state.managedUltraworkSessions.get(sessionID);
|
|
215
227
|
if (!managedSession)
|
|
216
|
-
return;
|
|
228
|
+
return null;
|
|
217
229
|
const retryAgent = managedSession.agent || ULTRAWORK_AGENT;
|
|
218
230
|
const fallbackModel = managedSession.fallbackModel;
|
|
219
231
|
const retryText = retryAgent === ULTRAWORK_AGENT
|
|
220
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.]"
|
|
221
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
|
+
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", {
|
|
247
|
+
sessionID,
|
|
248
|
+
reason,
|
|
249
|
+
retryAgent,
|
|
250
|
+
managedKind: managedSession.kind,
|
|
251
|
+
rootSessionID: managedSession.rootSessionID,
|
|
252
|
+
fallbackModel,
|
|
253
|
+
});
|
|
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
|
+
}
|
|
267
|
+
}
|
|
222
268
|
await state.input.client.session.promptAsync({
|
|
223
269
|
body: {
|
|
224
270
|
...(fallbackModel ? { model: fallbackModel } : {}),
|
|
@@ -232,10 +278,49 @@ async function forceRetryManagedSession(state, sessionID, reason) {
|
|
|
232
278
|
},
|
|
233
279
|
path: { id: sessionID },
|
|
234
280
|
});
|
|
235
|
-
console.log(`[opencode-immune]
|
|
281
|
+
console.log(`[opencode-immune] Retry prompt sent to session ${sessionID} (${reason})` +
|
|
236
282
|
(fallbackModel
|
|
237
283
|
? ` using fallback model ${fallbackModel.providerID}/${fallbackModel.modelID}`
|
|
238
284
|
: ""));
|
|
285
|
+
await writeDiagnosticLog(state, "session-retry:prompt-sent", {
|
|
286
|
+
sessionID,
|
|
287
|
+
reason,
|
|
288
|
+
retryAgent,
|
|
289
|
+
fallbackModel,
|
|
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;
|
|
239
324
|
}
|
|
240
325
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
241
326
|
// UTILITY: ERROR BOUNDARY
|
|
@@ -675,68 +760,39 @@ function createEventHandler(state) {
|
|
|
675
760
|
const eventType = event.type ?? "unknown";
|
|
676
761
|
const info = event.properties?.info;
|
|
677
762
|
const sessionID = event.properties?.sessionID ?? info?.id;
|
|
678
|
-
// ── Auto-retry on retryable API error for managed ultrawork sessions ──
|
|
763
|
+
// ── Auto-retry on retryable API error for managed root ultrawork sessions ──
|
|
679
764
|
if (eventType === "session.error" && sessionID) {
|
|
680
765
|
const error = event.properties?.error;
|
|
681
|
-
if (!
|
|
766
|
+
if (!isManagedRootUltraworkSession(state, sessionID)) {
|
|
682
767
|
return;
|
|
683
768
|
}
|
|
684
769
|
if (!isRetryableApiError(error)) {
|
|
685
|
-
|
|
686
|
-
state.
|
|
770
|
+
cancelPendingSessionRetry(state, sessionID, "non-retryable or user-aborted error");
|
|
771
|
+
state.sessionErrorRetryCount.delete(sessionID);
|
|
687
772
|
return;
|
|
688
773
|
}
|
|
689
|
-
if (state.
|
|
774
|
+
if (state.sessionRetryTimers.has(sessionID)) {
|
|
690
775
|
console.log(`[opencode-immune] Retry already pending for session ${sessionID}, skipping duplicate.`);
|
|
691
776
|
return;
|
|
692
777
|
}
|
|
693
|
-
const count = state.
|
|
778
|
+
const count = state.sessionErrorRetryCount.get(sessionID) ?? 0;
|
|
694
779
|
if (count < MAX_RETRIES) {
|
|
695
780
|
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, count), MAX_DELAY_MS);
|
|
696
|
-
state.
|
|
781
|
+
state.sessionErrorRetryCount.set(sessionID, count + 1);
|
|
697
782
|
if (isRateLimitApiError(error)) {
|
|
698
783
|
await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
|
|
699
784
|
console.log(`[opencode-immune] Rate limit detected for session ${sessionID}. ` +
|
|
700
785
|
`Retry will use fallback model ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
|
|
701
786
|
}
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
state.retryTimers.delete(sessionID);
|
|
712
|
-
if (!isManagedUltraworkSession(state, sessionID)) {
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
try {
|
|
716
|
-
await state.input.client.session.promptAsync({
|
|
717
|
-
body: {
|
|
718
|
-
...(fallbackModel ? { model: fallbackModel } : {}),
|
|
719
|
-
agent: retryAgent,
|
|
720
|
-
parts: [
|
|
721
|
-
{
|
|
722
|
-
type: "text",
|
|
723
|
-
text: retryText,
|
|
724
|
-
},
|
|
725
|
-
],
|
|
726
|
-
},
|
|
727
|
-
path: { id: sessionID },
|
|
728
|
-
});
|
|
729
|
-
console.log(`[opencode-immune] Auto-retry message sent to session ${sessionID}` +
|
|
730
|
-
(fallbackModel
|
|
731
|
-
? ` using fallback model ${fallbackModel.providerID}/${fallbackModel.modelID}`
|
|
732
|
-
: ""));
|
|
733
|
-
}
|
|
734
|
-
catch (err) {
|
|
735
|
-
state.retryCount.set(sessionID, Math.max((state.retryCount.get(sessionID) ?? 1) - 1, 0));
|
|
736
|
-
console.log(`[opencode-immune] Auto-retry failed (still offline?). Will retry on next error event.`);
|
|
737
|
-
}
|
|
738
|
-
}, delay);
|
|
739
|
-
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
|
+
}
|
|
740
796
|
}
|
|
741
797
|
else {
|
|
742
798
|
console.log(`[opencode-immune] Max retries (${MAX_RETRIES}) reached for session ${sessionID}. Not retrying.`);
|
|
@@ -744,8 +800,8 @@ function createEventHandler(state) {
|
|
|
744
800
|
}
|
|
745
801
|
// Reset retry counter on successful activity
|
|
746
802
|
if (eventType === "session.updated" && sessionID) {
|
|
747
|
-
|
|
748
|
-
state.
|
|
803
|
+
cancelPendingSessionRetry(state, sessionID, "session updated");
|
|
804
|
+
state.sessionErrorRetryCount.delete(sessionID);
|
|
749
805
|
if (markUltraworkSessionActive(state, sessionID)) {
|
|
750
806
|
await writeManagedSessionsCache(state);
|
|
751
807
|
}
|
|
@@ -795,28 +851,27 @@ function createMultiCycleHandler(state) {
|
|
|
795
851
|
messageContent = messageContent.trim();
|
|
796
852
|
if (!messageContent)
|
|
797
853
|
return;
|
|
798
|
-
|
|
799
|
-
|
|
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)) {
|
|
800
861
|
if (managedSession && !managedSession.fallbackModel) {
|
|
801
862
|
await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
|
|
802
863
|
console.log(`[opencode-immune] Rate limit message detected in chat output for session ${sessionID}. ` +
|
|
803
864
|
`Fallback model pinned to ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
|
|
804
865
|
}
|
|
805
|
-
if (managedSession
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
console.error(`[opencode-immune] Forced retry after rate-limit message failed for session ${sessionID}:`, err);
|
|
813
|
-
}
|
|
814
|
-
}, 1_000);
|
|
815
|
-
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
|
+
});
|
|
816
873
|
}
|
|
817
874
|
}
|
|
818
|
-
if (!isManagedRootUltraworkSession(state, sessionID))
|
|
819
|
-
return;
|
|
820
875
|
// ── PRE_COMMIT: execute /commit ──
|
|
821
876
|
if (messageContent.includes(PRE_COMMIT_MARKER) && !state.commitPending) {
|
|
822
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
|
+
}
|