opencode-immune 1.0.57 → 1.0.59
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 +115 -137
- package/package.json +1 -1
package/dist/plugin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// .opencode/plugin.ts — opencode-immune plugin
|
|
2
2
|
// Hybrid single-file architecture with factory functions, explicit state, error boundaries
|
|
3
3
|
// See: memory-bank/creative/creative-plugin-architecture.md (Option C)
|
|
4
|
-
import { createOpencodeClient as createOpencodeClientV2 } from "@opencode-ai/sdk/v2
|
|
4
|
+
import { createOpencodeClient as createOpencodeClientV2 } from "@opencode-ai/sdk/v2";
|
|
5
5
|
import { appendFile, mkdir, readFile, unlink, writeFile, stat, rm, rename, readdir, copyFile } from "fs/promises";
|
|
6
6
|
import { join, dirname } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
@@ -11,7 +11,7 @@ import { execFile } from "child_process";
|
|
|
11
11
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
12
12
|
// PLUGIN VERSION CHECK
|
|
13
13
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
14
|
-
const PLUGIN_VERSION = "1.0.
|
|
14
|
+
const PLUGIN_VERSION = "1.0.59";
|
|
15
15
|
const PLUGIN_PACKAGE_NAME = "opencode-immune";
|
|
16
16
|
const PLUGIN_DIRNAME = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
function getServerAuthHeaders() {
|
|
@@ -468,146 +468,114 @@ function getFallbackAgentForAgent(state, agent) {
|
|
|
468
468
|
function getRouterSessionIDForChild(record) {
|
|
469
469
|
return record.parentSessionID ?? record.rootSessionID;
|
|
470
470
|
}
|
|
471
|
-
function
|
|
472
|
-
if (
|
|
473
|
-
return
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
471
|
+
function stringifyErrorField(value) {
|
|
472
|
+
if (value === undefined || value === null)
|
|
473
|
+
return undefined;
|
|
474
|
+
if (typeof value === "string")
|
|
475
|
+
return value;
|
|
476
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
477
|
+
return String(value);
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
function numberFromErrorField(value) {
|
|
481
|
+
if (typeof value === "number")
|
|
482
|
+
return value;
|
|
483
|
+
if (typeof value !== "string")
|
|
484
|
+
return undefined;
|
|
485
|
+
const parsed = Number(value);
|
|
486
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
487
|
+
}
|
|
488
|
+
function firstDefinedNumber(...values) {
|
|
489
|
+
for (const value of values) {
|
|
490
|
+
const parsed = numberFromErrorField(value);
|
|
491
|
+
if (parsed !== undefined)
|
|
492
|
+
return parsed;
|
|
490
493
|
}
|
|
491
|
-
|
|
492
|
-
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
function extractManagedErrorDetails(error) {
|
|
497
|
+
if (!isRecord(error))
|
|
498
|
+
return undefined;
|
|
499
|
+
const data = isRecord(error.data) ? error.data : undefined;
|
|
500
|
+
const nestedError = isRecord(error.error) ? error.error : undefined;
|
|
501
|
+
const name = stringifyErrorField(error.name) ?? "";
|
|
502
|
+
const status = firstDefinedNumber(error.status, error.statusCode, data?.status, nestedError?.status);
|
|
503
|
+
const code = [error.code, data?.code, nestedError?.code]
|
|
504
|
+
.map(stringifyErrorField)
|
|
505
|
+
.find(Boolean);
|
|
506
|
+
const type = [error.type, data?.type, nestedError?.type]
|
|
507
|
+
.map(stringifyErrorField)
|
|
508
|
+
.find(Boolean);
|
|
509
|
+
const isRetryable = error.isRetryable === true || data?.isRetryable === true || nestedError?.isRetryable === true
|
|
510
|
+
? true
|
|
511
|
+
: undefined;
|
|
493
512
|
const message = [
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
message.includes("validation") ||
|
|
514
|
-
message.includes("schema") ||
|
|
515
|
-
message.includes("zod") ||
|
|
516
|
-
message.includes("parse error") ||
|
|
517
|
-
message.includes("invalid id") ||
|
|
518
|
-
message.includes("expected id") ||
|
|
519
|
-
message.includes("model not available") ||
|
|
520
|
-
message.includes("model_not_found") ||
|
|
521
|
-
message.includes("access denied") ||
|
|
522
|
-
message.includes("404") ||
|
|
523
|
-
message.includes("not found") ||
|
|
524
|
-
message.includes("page not found") ||
|
|
525
|
-
message.includes("502") ||
|
|
526
|
-
message.includes("bad gateway") ||
|
|
527
|
-
message.includes("503") ||
|
|
528
|
-
message.includes("service unavailable") ||
|
|
529
|
-
message.includes("504") ||
|
|
530
|
-
message.includes("gateway timeout") ||
|
|
531
|
-
message.includes("econnrefused") ||
|
|
532
|
-
message.includes("econnreset") ||
|
|
533
|
-
message.includes("etimedout") ||
|
|
534
|
-
message.includes("fetch failed") ||
|
|
535
|
-
message.includes("timed out") ||
|
|
536
|
-
message.includes("timeout") ||
|
|
537
|
-
message.includes("sse read") ||
|
|
538
|
-
message.includes("stream error") ||
|
|
539
|
-
// Certificate/TLS provider failures must pass this primary retry gate
|
|
540
|
-
// before the managed-session fallback model branch can run.
|
|
541
|
-
message.includes("unknown certificate verification error") ||
|
|
542
|
-
message.includes("unknown_certificate_verification_error") ||
|
|
543
|
-
message.includes("certificate has expired") ||
|
|
544
|
-
message.includes("certificate verification") ||
|
|
545
|
-
message.includes("tls") ||
|
|
546
|
-
message.includes("ssl") ||
|
|
547
|
-
message.includes("connection reset") ||
|
|
548
|
-
message.includes("socket hang up") ||
|
|
549
|
-
message.includes("aborted")) {
|
|
550
|
-
return true;
|
|
551
|
-
}
|
|
552
|
-
return false;
|
|
513
|
+
error.message,
|
|
514
|
+
data?.message,
|
|
515
|
+
nestedError?.message,
|
|
516
|
+
code,
|
|
517
|
+
type,
|
|
518
|
+
].map(stringifyErrorField).filter(Boolean).join(" ").toLowerCase();
|
|
519
|
+
return {
|
|
520
|
+
name,
|
|
521
|
+
message,
|
|
522
|
+
status,
|
|
523
|
+
code,
|
|
524
|
+
type,
|
|
525
|
+
isRetryable,
|
|
526
|
+
hasApiShape: name === "APIError" ||
|
|
527
|
+
status !== undefined ||
|
|
528
|
+
isRetryable !== undefined ||
|
|
529
|
+
data !== undefined ||
|
|
530
|
+
nestedError !== undefined,
|
|
531
|
+
};
|
|
553
532
|
}
|
|
554
|
-
function
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (
|
|
572
|
-
return
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
533
|
+
function getManagedSessionErrorAction(error) {
|
|
534
|
+
const details = extractManagedErrorDetails(error);
|
|
535
|
+
if (!details)
|
|
536
|
+
return "none";
|
|
537
|
+
if (details.name === "AbortError" || details.code === "ABORT_ERR") {
|
|
538
|
+
return "none";
|
|
539
|
+
}
|
|
540
|
+
if (details.status !== undefined) {
|
|
541
|
+
if (details.status === 408 || details.status === 409 || details.status === 425 || details.status >= 500) {
|
|
542
|
+
return "retry";
|
|
543
|
+
}
|
|
544
|
+
if (details.status >= 400 && details.status < 500) {
|
|
545
|
+
return "fallback";
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (details.isRetryable === true)
|
|
549
|
+
return "retry";
|
|
550
|
+
if (details.code && /^E[A-Z0-9_]+$/.test(details.code))
|
|
551
|
+
return "retry";
|
|
552
|
+
// Managed session errors originate from the active model/provider request.
|
|
553
|
+
// If the server emitted an API-shaped error but did not expose a stable status
|
|
554
|
+
// or retryability flag, prefer one fallback-model attempt over hard-stopping.
|
|
555
|
+
if (details.hasApiShape || details.message)
|
|
556
|
+
return "fallback";
|
|
557
|
+
return "none";
|
|
558
|
+
}
|
|
559
|
+
function shouldUseFallbackForManagedError(error, managedSession, retryCount) {
|
|
560
|
+
if (managedSession.kind === "child")
|
|
561
|
+
return true;
|
|
562
|
+
return getManagedSessionErrorAction(error) === "fallback" || retryCount > 0;
|
|
577
563
|
}
|
|
578
|
-
function
|
|
579
|
-
|
|
580
|
-
return false;
|
|
581
|
-
const maybeError = error;
|
|
582
|
-
const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""} ${maybeError.error?.message ?? ""}`.toLowerCase();
|
|
583
|
-
const code = `${maybeError.code ?? ""} ${maybeError.data?.code ?? ""} ${maybeError.error?.code ?? ""}`.toLowerCase();
|
|
584
|
-
const type = `${maybeError.data?.type ?? ""} ${maybeError.error?.type ?? ""}`.toLowerCase();
|
|
585
|
-
return (message.includes("unknown certificate verification error") ||
|
|
586
|
-
message.includes("unknown_certificate_verification_error") ||
|
|
587
|
-
message.includes("certificate has expired") ||
|
|
588
|
-
message.includes("certificate verification") ||
|
|
589
|
-
message.includes("tls") ||
|
|
590
|
-
message.includes("ssl") ||
|
|
591
|
-
code.includes("cert_has_expired") ||
|
|
592
|
-
code.includes("unknown_certificate_verification_error") ||
|
|
593
|
-
code.includes("unable_to_verify_leaf_signature") ||
|
|
594
|
-
code.includes("self_signed_cert") ||
|
|
595
|
-
code.includes("tls") ||
|
|
596
|
-
type.includes("certificate") ||
|
|
597
|
-
type.includes("tls") ||
|
|
598
|
-
type.includes("ssl"));
|
|
564
|
+
function isRetryableApiError(error) {
|
|
565
|
+
return getManagedSessionErrorAction(error) !== "none";
|
|
599
566
|
}
|
|
600
567
|
function isProviderRetryBanner(text) {
|
|
601
568
|
return /(?:<none>\s*)?retrying in \d+s\s*-\s*attempt #\d+/i.test(text);
|
|
602
569
|
}
|
|
603
570
|
function getRetryableErrorType(error) {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
571
|
+
const action = getManagedSessionErrorAction(error);
|
|
572
|
+
const status = extractManagedErrorDetails(error)?.status;
|
|
573
|
+
const statusSuffix = status !== undefined ? ` (${status})` : "";
|
|
574
|
+
if (action === "fallback")
|
|
575
|
+
return `provider fallback error${statusSuffix}`;
|
|
576
|
+
if (action === "retry")
|
|
577
|
+
return `transient provider error${statusSuffix}`;
|
|
578
|
+
return "non-retryable provider error";
|
|
611
579
|
}
|
|
612
580
|
function recordChildFallbackRequest(state, managedSession, childSessionID, error) {
|
|
613
581
|
const fallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
|
|
@@ -1713,19 +1681,29 @@ function createEventHandler(state) {
|
|
|
1713
1681
|
const info = event.properties?.info;
|
|
1714
1682
|
const sessionID = event.properties?.sessionID ?? info?.id;
|
|
1715
1683
|
const error = event.properties?.error;
|
|
1684
|
+
if (eventType === "session.error") {
|
|
1685
|
+
await writeDiagnosticLog(state, "session-error:observed", {
|
|
1686
|
+
sessionID,
|
|
1687
|
+
action: getManagedSessionErrorAction(error),
|
|
1688
|
+
errorType: getRetryableErrorType(error),
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1716
1691
|
// Fallback: some SDK/schema errors can arrive without a valid sessionID
|
|
1717
1692
|
// (for example: "Expected 'id' to be a string."). If there is exactly one
|
|
1718
1693
|
// active managed root session, retry it as a best-effort recovery path.
|
|
1719
1694
|
if (eventType === "session.error" && !sessionID && isRetryableApiError(error)) {
|
|
1720
1695
|
const rootCandidates = Array.from(state.managedUltraworkSessions.entries())
|
|
1721
|
-
.filter(([, record]) => record.kind === "root")
|
|
1722
|
-
.map(([id]) => id);
|
|
1696
|
+
.filter(([, record]) => record.kind === "root");
|
|
1723
1697
|
if (rootCandidates.length === 1) {
|
|
1724
|
-
const fallbackSessionID = rootCandidates[0];
|
|
1698
|
+
const [fallbackSessionID, managedSession] = rootCandidates[0];
|
|
1725
1699
|
const count = state.sessionErrorRetryCount.get(fallbackSessionID) ?? 0;
|
|
1726
1700
|
if (count < MAX_RETRIES && !state.sessionRetryTimers.has(fallbackSessionID)) {
|
|
1727
1701
|
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, count), MAX_DELAY_MS);
|
|
1728
1702
|
state.sessionErrorRetryCount.set(fallbackSessionID, count + 1);
|
|
1703
|
+
if (shouldUseFallbackForManagedError(error, managedSession, count)) {
|
|
1704
|
+
const fallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
|
|
1705
|
+
await setSessionFallbackModel(state, fallbackSessionID, fallbackModel);
|
|
1706
|
+
}
|
|
1729
1707
|
pluginLog.warn(`[opencode-immune] session.error without sessionID matched retryable error. ` +
|
|
1730
1708
|
`Retrying sole managed root session ${fallbackSessionID}.`);
|
|
1731
1709
|
scheduleManagedSessionRetry(state, fallbackSessionID, {
|
|
@@ -1775,7 +1753,7 @@ function createEventHandler(state) {
|
|
|
1775
1753
|
state.sessionErrorRetryCount.set(sessionID, count);
|
|
1776
1754
|
return;
|
|
1777
1755
|
}
|
|
1778
|
-
else if (isRoot && (
|
|
1756
|
+
else if (isRoot && shouldUseFallbackForManagedError(error, managedSession, count)) {
|
|
1779
1757
|
const selectedFallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
|
|
1780
1758
|
await setSessionFallbackModel(state, sessionID, selectedFallbackModel);
|
|
1781
1759
|
const errorType = getRetryableErrorType(error);
|