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.
Files changed (2) hide show
  1. package/dist/plugin.js +104 -87
  2. 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
- 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
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
- cancelRetry(state, sessionID, "managed session TTL expired");
49
- state.retryCount.delete(sessionID);
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 cancelRetry(state, sessionID, reason) {
158
- const timer = state.retryTimers.get(sessionID);
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.retryTimers.delete(sessionID);
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
- cancelRetry(state, sessionID, reason);
167
- state.retryCount.delete(sessionID);
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
- async function forceRetryManagedSession(state, sessionID, reason) {
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
- await writeDiagnosticLog(state, "force-retry:start", {
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
- try {
243
- await state.input.client.session.abort({
244
- path: { id: sessionID },
245
- });
246
- await writeDiagnosticLog(state, "force-retry:abort-success", { sessionID });
247
- }
248
- catch (err) {
249
- await writeDiagnosticLog(state, "force-retry:abort-failed", {
250
- sessionID,
251
- error: err instanceof Error ? err.message : String(err),
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] Forced retry sent to session ${sessionID} (${reason})` +
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, "force-retry:prompt-sent", {
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 (!isManagedUltraworkSession(state, sessionID)) {
766
+ if (!isManagedRootUltraworkSession(state, sessionID)) {
720
767
  return;
721
768
  }
722
769
  if (!isRetryableApiError(error)) {
723
- cancelRetry(state, sessionID, "non-retryable or user-aborted error");
724
- state.retryCount.delete(sessionID);
770
+ cancelPendingSessionRetry(state, sessionID, "non-retryable or user-aborted error");
771
+ state.sessionErrorRetryCount.delete(sessionID);
725
772
  return;
726
773
  }
727
- if (state.retryTimers.has(sessionID)) {
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.retryCount.get(sessionID) ?? 0;
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.retryCount.set(sessionID, count + 1);
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 managedSession = state.managedUltraworkSessions.get(sessionID);
741
- const fallbackModel = managedSession?.fallbackModel;
742
- const retryAgent = managedSession?.agent || ULTRAWORK_AGENT;
743
- const retryText = retryAgent === ULTRAWORK_AGENT
744
- ? "[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.]"
745
- : `[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.]`;
746
- console.log(`[opencode-immune] Session error detected (attempt ${count + 1}/${MAX_RETRIES}). ` +
747
- `Waiting ${delay / 1000}s before retry...`);
748
- const timer = setTimeout(async () => {
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
- cancelRetry(state, sessionID, "session updated");
786
- state.retryCount.delete(sessionID);
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
- if (sessionID && RATE_LIMIT_MESSAGE_PATTERN.test(messageContent)) {
837
- 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)) {
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 && !state.retryTimers.has(sessionID)) {
844
- const timer = setTimeout(async () => {
845
- state.retryTimers.delete(sessionID);
846
- try {
847
- await forceRetryManagedSession(state, sessionID, "rate-limit message fallback");
848
- }
849
- catch (err) {
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.6",
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
+ }