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.
Files changed (2) hide show
  1. package/dist/plugin.js +129 -74
  2. 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
- retryTimers: new Map(),
14
- retryCount: new Map(),
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
- cancelRetry(state, sessionID, "managed session TTL expired");
48
- state.retryCount.delete(sessionID);
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 cancelRetry(state, sessionID, reason) {
146
- const timer = state.retryTimers.get(sessionID);
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.retryTimers.delete(sessionID);
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
- cancelRetry(state, sessionID, reason);
155
- state.retryCount.delete(sessionID);
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
- async function forceRetryManagedSession(state, sessionID, reason) {
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] Forced retry sent to session ${sessionID} (${reason})` +
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 (!isManagedUltraworkSession(state, sessionID)) {
766
+ if (!isManagedRootUltraworkSession(state, sessionID)) {
682
767
  return;
683
768
  }
684
769
  if (!isRetryableApiError(error)) {
685
- cancelRetry(state, sessionID, "non-retryable or user-aborted error");
686
- state.retryCount.delete(sessionID);
770
+ cancelPendingSessionRetry(state, sessionID, "non-retryable or user-aborted error");
771
+ state.sessionErrorRetryCount.delete(sessionID);
687
772
  return;
688
773
  }
689
- if (state.retryTimers.has(sessionID)) {
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.retryCount.get(sessionID) ?? 0;
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.retryCount.set(sessionID, count + 1);
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 managedSession = state.managedUltraworkSessions.get(sessionID);
703
- const fallbackModel = managedSession?.fallbackModel;
704
- const retryAgent = managedSession?.agent || ULTRAWORK_AGENT;
705
- const retryText = retryAgent === ULTRAWORK_AGENT
706
- ? "[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.]"
707
- : `[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.]`;
708
- console.log(`[opencode-immune] Session error detected (attempt ${count + 1}/${MAX_RETRIES}). ` +
709
- `Waiting ${delay / 1000}s before retry...`);
710
- const timer = setTimeout(async () => {
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
- cancelRetry(state, sessionID, "session updated");
748
- state.retryCount.delete(sessionID);
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
- if (sessionID && RATE_LIMIT_MESSAGE_PATTERN.test(messageContent)) {
799
- const managedSession = getManagedSession(state, sessionID);
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 && !state.retryTimers.has(sessionID)) {
806
- const timer = setTimeout(async () => {
807
- state.retryTimers.delete(sessionID);
808
- try {
809
- await forceRetryManagedSession(state, sessionID, "rate-limit message fallback");
810
- }
811
- catch (err) {
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.5",
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
+ }