@kodrunhq/opencode-autopilot 1.18.0 → 1.19.0

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 (110) hide show
  1. package/README.md +95 -13
  2. package/assets/commands/oc-update-docs.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agents/index.ts +0 -12
  5. package/src/agents/pipeline/index.ts +0 -4
  6. package/src/autonomy/completion.ts +52 -0
  7. package/src/autonomy/controller.ts +144 -0
  8. package/src/autonomy/index.ts +25 -0
  9. package/src/autonomy/injector.ts +49 -0
  10. package/src/autonomy/state.ts +91 -0
  11. package/src/autonomy/types.ts +30 -0
  12. package/src/autonomy/verification.ts +86 -0
  13. package/src/background/database.ts +170 -0
  14. package/src/background/executor.ts +174 -0
  15. package/src/background/index.ts +8 -0
  16. package/src/background/manager.ts +232 -0
  17. package/src/background/repository.ts +174 -0
  18. package/src/background/schema.ts +24 -0
  19. package/src/background/sdk-runner.ts +40 -0
  20. package/src/background/slot-manager.ts +41 -0
  21. package/src/background/state-machine.ts +19 -0
  22. package/src/context/budget.ts +45 -0
  23. package/src/context/compaction-handler.ts +58 -0
  24. package/src/context/discovery.ts +94 -0
  25. package/src/context/index.ts +14 -0
  26. package/src/context/injector.ts +119 -0
  27. package/src/context/types.ts +24 -0
  28. package/src/health/checks.ts +145 -2
  29. package/src/health/index.ts +7 -1
  30. package/src/health/runner.ts +6 -0
  31. package/src/index.ts +113 -6
  32. package/src/installer.ts +13 -0
  33. package/src/kernel/index.ts +6 -0
  34. package/src/kernel/migrations.ts +50 -0
  35. package/src/kernel/retry.ts +49 -0
  36. package/src/kernel/schema.ts +9 -1
  37. package/src/kernel/transaction.ts +40 -12
  38. package/src/logging/forensic-writer.ts +6 -2
  39. package/src/logging/index.ts +2 -0
  40. package/src/mcp/index.ts +34 -0
  41. package/src/mcp/manager.ts +206 -0
  42. package/src/mcp/scope-filter.ts +44 -0
  43. package/src/mcp/types.ts +38 -0
  44. package/src/orchestrator/arena.ts +7 -1
  45. package/src/orchestrator/fallback/event-handler.ts +12 -1
  46. package/src/orchestrator/handlers/challenge.ts +8 -1
  47. package/src/orchestrator/handlers/plan.ts +8 -1
  48. package/src/orchestrator/handlers/recon.ts +8 -1
  49. package/src/orchestrator/handlers/types.ts +2 -2
  50. package/src/orchestrator/lesson-memory.ts +6 -1
  51. package/src/orchestrator/orchestration-logger.ts +15 -3
  52. package/src/orchestrator/skill-injection.ts +7 -1
  53. package/src/orchestrator/state.ts +6 -1
  54. package/src/recovery/classifier.ts +127 -0
  55. package/src/recovery/event-handler.ts +263 -0
  56. package/src/recovery/index.ts +20 -0
  57. package/src/recovery/orchestrator.ts +180 -0
  58. package/src/recovery/persistence.ts +87 -0
  59. package/src/recovery/strategies.ts +107 -0
  60. package/src/recovery/types.ts +31 -0
  61. package/src/registry/model-groups.ts +2 -19
  62. package/src/registry/resolver.ts +38 -9
  63. package/src/review/agent-catalog.ts +83 -251
  64. package/src/review/agents/architecture-verifier.ts +41 -0
  65. package/src/review/agents/code-hygiene-auditor.ts +40 -0
  66. package/src/review/agents/correctness-auditor.ts +41 -0
  67. package/src/review/agents/frontend-auditor.ts +39 -0
  68. package/src/review/agents/index.ts +15 -42
  69. package/src/review/agents/language-idioms-auditor.ts +39 -0
  70. package/src/review/agents/security-auditor.ts +12 -8
  71. package/src/review/stack-gate.ts +2 -6
  72. package/src/routing/categories.ts +111 -0
  73. package/src/routing/classifier.ts +152 -0
  74. package/src/routing/engine.ts +89 -0
  75. package/src/routing/index.ts +4 -0
  76. package/src/routing/types.ts +14 -0
  77. package/src/skills/adaptive-injector.ts +34 -3
  78. package/src/skills/loader.ts +4 -0
  79. package/src/tools/background.ts +196 -0
  80. package/src/tools/delegate.ts +205 -0
  81. package/src/tools/loop.ts +94 -0
  82. package/src/tools/recover.ts +172 -0
  83. package/src/types/recovery.ts +10 -0
  84. package/src/ux/context-warnings.ts +81 -0
  85. package/src/ux/error-hints.ts +38 -0
  86. package/src/ux/index.ts +7 -0
  87. package/src/ux/notifications.ts +67 -0
  88. package/src/ux/progress.ts +77 -0
  89. package/src/ux/session-summary.ts +67 -0
  90. package/src/ux/task-status.ts +109 -0
  91. package/src/ux/types.ts +24 -0
  92. package/src/agents/db-specialist.ts +0 -295
  93. package/src/agents/devops.ts +0 -352
  94. package/src/agents/documenter.ts +0 -44
  95. package/src/agents/frontend-engineer.ts +0 -541
  96. package/src/agents/pipeline/oc-explorer.ts +0 -46
  97. package/src/agents/pipeline/oc-retrospector.ts +0 -42
  98. package/src/review/agents/auth-flow-verifier.ts +0 -47
  99. package/src/review/agents/concurrency-checker.ts +0 -47
  100. package/src/review/agents/dead-code-scanner.ts +0 -47
  101. package/src/review/agents/go-idioms-auditor.ts +0 -46
  102. package/src/review/agents/python-django-auditor.ts +0 -46
  103. package/src/review/agents/react-patterns-auditor.ts +0 -46
  104. package/src/review/agents/rust-safety-auditor.ts +0 -46
  105. package/src/review/agents/scope-intent-verifier.ts +0 -45
  106. package/src/review/agents/silent-failure-hunter.ts +0 -45
  107. package/src/review/agents/spec-checker.ts +0 -45
  108. package/src/review/agents/state-mgmt-auditor.ts +0 -46
  109. package/src/review/agents/type-soundness.ts +0 -46
  110. package/src/review/agents/wiring-inspector.ts +0 -46
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { readFile } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
+ import { getLogger } from "../logging/domains";
12
13
  import { sanitizeTemplateContent } from "../review/sanitize";
13
14
  import {
14
15
  buildAdaptiveSkillContext,
@@ -20,6 +21,7 @@ import { loadAllSkills } from "../skills/loader";
20
21
  import { isEnoentError } from "../utils/fs-helpers";
21
22
 
22
23
  const MAX_SKILL_LENGTH = 2048;
24
+ const logger = getLogger("orchestrator", "skill-injection");
23
25
 
24
26
  /**
25
27
  * Load the coding-standards skill content from the global config dir.
@@ -74,6 +76,7 @@ export async function loadAdaptiveSkillContext(
74
76
  readonly phase?: string;
75
77
  readonly budget?: number;
76
78
  readonly mode?: SkillMode;
79
+ readonly mcpEnabled?: boolean;
77
80
  },
78
81
  ): Promise<string> {
79
82
  try {
@@ -89,7 +92,10 @@ export async function loadAdaptiveSkillContext(
89
92
  return buildAdaptiveSkillContext(matchingSkills, options);
90
93
  } catch (err) {
91
94
  // Best-effort: all errors return empty string.
92
- console.warn("[opencode-autopilot] adaptive skill load failed:", err);
95
+ logger.warn("adaptive skill load failed", {
96
+ operation: "skill_injection",
97
+ error: err instanceof Error ? err.message : String(err),
98
+ });
93
99
  return "";
94
100
  }
95
101
  }
@@ -3,6 +3,7 @@ import { readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { loadLatestPipelineStateFromKernel, savePipelineStateToKernel } from "../kernel/repository";
5
5
  import { KERNEL_STATE_CONFLICT_CODE } from "../kernel/types";
6
+ import { getLogger } from "../logging/domains";
6
7
  import { ensureDir, isEnoentError } from "../utils/fs-helpers";
7
8
  import { assertStateInvariants } from "./contracts/invariants";
8
9
  import { PHASES, pipelineStateSchema } from "./schemas";
@@ -10,6 +11,7 @@ import type { PipelineState } from "./types";
10
11
 
11
12
  const STATE_FILE = "state.json";
12
13
  let legacyStateMirrorWarned = false;
14
+ const logger = getLogger("orchestrator", "state");
13
15
 
14
16
  function generateRunId(): string {
15
17
  return `run_${randomBytes(8).toString("hex")}`;
@@ -70,7 +72,10 @@ async function syncLegacyStateMirror(state: PipelineState, artifactDir: string):
70
72
  } catch (error: unknown) {
71
73
  if (!legacyStateMirrorWarned) {
72
74
  legacyStateMirrorWarned = true;
73
- console.warn("[opencode-autopilot] state.json mirror write failed:", error);
75
+ logger.warn("state.json mirror write failed", {
76
+ operation: "legacy_state_mirror",
77
+ error: error instanceof Error ? error.message : String(error),
78
+ });
74
79
  }
75
80
  }
76
81
  }
@@ -0,0 +1,127 @@
1
+ import type { ErrorCategory } from "../types/recovery";
2
+ import { ErrorCategorySchema } from "../types/recovery";
3
+ import type { ClassificationResult } from "./types";
4
+
5
+ const CLASSIFICATION_RULES: readonly {
6
+ readonly category: ErrorCategory;
7
+ readonly patterns: readonly RegExp[];
8
+ readonly confidence: number;
9
+ readonly reasoning: string;
10
+ }[] = Object.freeze([
11
+ {
12
+ category: "empty_content",
13
+ patterns: [/empty\s+content/i, /no\s+content/i, /empty\s+response/i],
14
+ confidence: 0.98,
15
+ reasoning: "Matched empty response pattern",
16
+ },
17
+ {
18
+ category: "thinking_block_error",
19
+ patterns: [/thinking\s+block/i, /reasoning\s+failed/i],
20
+ confidence: 0.97,
21
+ reasoning: "Matched reasoning failure pattern",
22
+ },
23
+ {
24
+ category: "tool_result_overflow",
25
+ patterns: [/result\s+too\s+large/i, /output\s+exceeded/i, /overflow/i],
26
+ confidence: 0.97,
27
+ reasoning: "Matched oversized tool output pattern",
28
+ },
29
+ {
30
+ category: "context_window_exceeded",
31
+ patterns: [/context\s+window/i, /token\s+limit/i, /max\s+tokens/i, /context\s+length/i],
32
+ confidence: 0.98,
33
+ reasoning: "Matched context exhaustion pattern",
34
+ },
35
+ {
36
+ category: "session_corruption",
37
+ patterns: [/session\s+corrupt/i, /invalid\s+state/i, /state\s+mismatch/i],
38
+ confidence: 0.99,
39
+ reasoning: "Matched session state corruption pattern",
40
+ },
41
+ {
42
+ category: "agent_loop_stuck",
43
+ patterns: [/loop\s+detected/i, /infinite\s+loop/i, /stuck/i, /no\s+progress/i],
44
+ confidence: 0.95,
45
+ reasoning: "Matched agent loop or no-progress pattern",
46
+ },
47
+ {
48
+ category: "rate_limit",
49
+ patterns: [/rate\s*limit/i, /too\s+many\s+requests/i, /\b429\b/],
50
+ confidence: 0.96,
51
+ reasoning: "Matched rate limit pattern",
52
+ },
53
+ {
54
+ category: "auth_failure",
55
+ patterns: [/auth/i, /unauthori[sz]ed/i, /forbidden/i, /api\s*key/i, /credential/i],
56
+ confidence: 0.94,
57
+ reasoning: "Matched authentication or authorization pattern",
58
+ },
59
+ {
60
+ category: "quota_exceeded",
61
+ patterns: [/quota/i, /credit\s+balance/i, /insufficient\s+(credits?|funds?|balance)/i],
62
+ confidence: 0.96,
63
+ reasoning: "Matched quota exhaustion pattern",
64
+ },
65
+ {
66
+ category: "service_unavailable",
67
+ patterns: [/service\s+unavailable/i, /temporarily\s+unavailable/i, /overloaded/i, /\b503\b/],
68
+ confidence: 0.95,
69
+ reasoning: "Matched service availability pattern",
70
+ },
71
+ {
72
+ category: "timeout",
73
+ patterns: [/timeout/i, /timed\s+out/i, /deadline\s+exceeded/i],
74
+ confidence: 0.94,
75
+ reasoning: "Matched timeout pattern",
76
+ },
77
+ {
78
+ category: "network",
79
+ patterns: [/network/i, /connection\s+reset/i, /socket\s+hang\s+up/i, /econnreset/i],
80
+ confidence: 0.93,
81
+ reasoning: "Matched network transport pattern",
82
+ },
83
+ {
84
+ category: "validation",
85
+ patterns: [/validation/i, /invalid\s+request/i, /malformed/i, /schema/i],
86
+ confidence: 0.92,
87
+ reasoning: "Matched validation pattern",
88
+ },
89
+ ]);
90
+
91
+ const NON_RECOVERABLE_CATEGORIES = new Set<ErrorCategory>(["auth_failure", "session_corruption"]);
92
+
93
+ function getErrorMessage(error: Error | string): string {
94
+ return typeof error === "string" ? error : error.message;
95
+ }
96
+
97
+ function isKnownBaseCategory(category: string): category is ErrorCategory {
98
+ return ErrorCategorySchema.safeParse(category).success;
99
+ }
100
+
101
+ export function classifyError(
102
+ error: Error | string,
103
+ context?: Record<string, unknown>,
104
+ ): ClassificationResult {
105
+ const message = getErrorMessage(error);
106
+ const contextText = context ? JSON.stringify(context) : "";
107
+ const haystack = `${message}\n${contextText}`;
108
+
109
+ for (const rule of CLASSIFICATION_RULES) {
110
+ if (rule.patterns.some((pattern) => pattern.test(haystack))) {
111
+ return Object.freeze({
112
+ category: rule.category,
113
+ confidence: rule.confidence,
114
+ reasoning: rule.reasoning,
115
+ isRecoverable: !NON_RECOVERABLE_CATEGORIES.has(rule.category),
116
+ });
117
+ }
118
+ }
119
+
120
+ const category: ErrorCategory = isKnownBaseCategory("unknown") ? "unknown" : "validation";
121
+ return Object.freeze({
122
+ category,
123
+ confidence: 0.35,
124
+ reasoning: "No known recovery pattern matched",
125
+ isRecoverable: !NON_RECOVERABLE_CATEGORIES.has(category),
126
+ });
127
+ }
@@ -0,0 +1,263 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { getLogger } from "../logging/domains";
3
+ import type { RecoveryAction } from "../types/recovery";
4
+ import type { RecoveryOrchestrator } from "./orchestrator";
5
+ import { clearRecoveryState } from "./persistence";
6
+
7
+ const logger = getLogger("recovery", "event-handler");
8
+
9
+ function getEventProperties(event: { readonly [key: string]: unknown }): Record<string, unknown> {
10
+ const properties = event.properties;
11
+ return properties !== null && typeof properties === "object"
12
+ ? (properties as Record<string, unknown>)
13
+ : {};
14
+ }
15
+
16
+ function extractSessionId(properties: Record<string, unknown>): string | null {
17
+ if (typeof properties.sessionID === "string") {
18
+ return properties.sessionID;
19
+ }
20
+
21
+ const info = properties.info;
22
+ if (
23
+ info !== null &&
24
+ typeof info === "object" &&
25
+ typeof (info as Record<string, unknown>).id === "string"
26
+ ) {
27
+ return (info as Record<string, unknown>).id as string;
28
+ }
29
+
30
+ if (
31
+ info !== null &&
32
+ typeof info === "object" &&
33
+ typeof (info as Record<string, unknown>).sessionID === "string"
34
+ ) {
35
+ return (info as Record<string, unknown>).sessionID as string;
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ function extractError(properties: Record<string, unknown>): Error | string | null {
42
+ if (properties.error instanceof Error || typeof properties.error === "string") {
43
+ return properties.error;
44
+ }
45
+
46
+ const info = properties.info;
47
+ if (info !== null && typeof info === "object") {
48
+ const nestedError = (info as Record<string, unknown>).error;
49
+ if (nestedError instanceof Error || typeof nestedError === "string") {
50
+ return nestedError;
51
+ }
52
+ if (
53
+ nestedError !== null &&
54
+ typeof nestedError === "object" &&
55
+ typeof (nestedError as Record<string, unknown>).message === "string"
56
+ ) {
57
+ return new Error((nestedError as Record<string, unknown>).message as string);
58
+ }
59
+ }
60
+
61
+ if (properties.error !== null && typeof properties.error === "object") {
62
+ const message = (properties.error as Record<string, unknown>).message;
63
+ if (typeof message === "string") {
64
+ return new Error(message);
65
+ }
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ export interface RecoverySdkOperations {
72
+ readonly abortSession?: (sessionId: string) => Promise<void>;
73
+ readonly showToast?: (title: string, message: string, variant: string) => Promise<void>;
74
+ }
75
+
76
+ function executeRecoveryAction(
77
+ action: RecoveryAction,
78
+ sessionId: string,
79
+ orchestrator: RecoveryOrchestrator,
80
+ sdk?: RecoverySdkOperations,
81
+ ): void {
82
+ logger.info("Executing recovery action", {
83
+ sessionId,
84
+ strategy: action.strategy,
85
+ errorCategory: action.errorCategory,
86
+ backoffMs: action.backoffMs,
87
+ maxAttempts: action.maxAttempts,
88
+ });
89
+
90
+ switch (action.strategy) {
91
+ case "retry": {
92
+ if (action.backoffMs > 0) {
93
+ setTimeout(() => {
94
+ orchestrator.recordResult(sessionId, true);
95
+ logger.info("Retry backoff completed", { sessionId, delayMs: action.backoffMs });
96
+ }, action.backoffMs);
97
+ } else {
98
+ orchestrator.recordResult(sessionId, true);
99
+ }
100
+ break;
101
+ }
102
+
103
+ case "fallback_model": {
104
+ orchestrator.recordResult(sessionId, true);
105
+ logger.info("Fallback model strategy triggered", {
106
+ sessionId,
107
+ metadata: action.metadata,
108
+ });
109
+ sdk
110
+ ?.showToast?.(
111
+ "Recovery",
112
+ `Switching model for session (${action.errorCategory})`,
113
+ "warning",
114
+ )
115
+ .catch(() => {});
116
+ break;
117
+ }
118
+
119
+ case "compact_and_retry": {
120
+ if (action.backoffMs > 0) {
121
+ setTimeout(() => {
122
+ orchestrator.recordResult(sessionId, true);
123
+ logger.info("Compact and retry completed", { sessionId });
124
+ }, action.backoffMs);
125
+ } else {
126
+ orchestrator.recordResult(sessionId, true);
127
+ }
128
+ break;
129
+ }
130
+
131
+ case "restart_session": {
132
+ logger.info("Session restart requested", { sessionId });
133
+ if (sdk?.abortSession) {
134
+ sdk
135
+ .abortSession(sessionId)
136
+ .then(() => {
137
+ orchestrator.recordResult(sessionId, true);
138
+ logger.info("Session aborted for restart", { sessionId });
139
+ })
140
+ .catch((error: unknown) => {
141
+ orchestrator.recordResult(sessionId, false);
142
+ logger.warn("Failed to abort session for restart", {
143
+ sessionId,
144
+ error: error instanceof Error ? error.message : String(error),
145
+ });
146
+ });
147
+ } else {
148
+ orchestrator.recordResult(sessionId, false);
149
+ logger.warn("No SDK operations available for session restart", { sessionId });
150
+ }
151
+ break;
152
+ }
153
+
154
+ case "reduce_context": {
155
+ orchestrator.recordResult(sessionId, true);
156
+ logger.info("Context reduction strategy applied", { sessionId });
157
+ break;
158
+ }
159
+
160
+ case "skip_and_continue": {
161
+ orchestrator.recordResult(sessionId, true);
162
+ logger.info("Skipping failed step and continuing", { sessionId });
163
+ sdk
164
+ ?.showToast?.("Recovery", "Skipped stuck step, continuing execution", "warning")
165
+ .catch(() => {});
166
+ break;
167
+ }
168
+
169
+ case "abort": {
170
+ orchestrator.recordResult(sessionId, false);
171
+ logger.warn("Recovery aborted — non-recoverable error", {
172
+ sessionId,
173
+ errorCategory: action.errorCategory,
174
+ });
175
+ sdk
176
+ ?.showToast?.("Recovery Failed", `Unrecoverable error: ${action.errorCategory}`, "error")
177
+ .catch(() => {});
178
+ break;
179
+ }
180
+
181
+ case "user_prompt": {
182
+ orchestrator.recordResult(sessionId, false);
183
+ logger.info("User intervention required", { sessionId, errorCategory: action.errorCategory });
184
+ sdk
185
+ ?.showToast?.("Action Required", `Recovery needs input: ${action.errorCategory}`, "warning")
186
+ .catch(() => {});
187
+ break;
188
+ }
189
+
190
+ default: {
191
+ orchestrator.recordResult(sessionId, false);
192
+ logger.warn("Unknown recovery strategy", { sessionId, strategy: action.strategy });
193
+ }
194
+ }
195
+ }
196
+
197
+ interface RecoveryEventHandlerOptions {
198
+ readonly orchestrator: RecoveryOrchestrator;
199
+ readonly db?: Database;
200
+ readonly sdk?: RecoverySdkOperations;
201
+ }
202
+
203
+ export function createRecoveryEventHandler(
204
+ orchestratorOrOptions: RecoveryOrchestrator | RecoveryEventHandlerOptions,
205
+ ) {
206
+ const options: RecoveryEventHandlerOptions =
207
+ orchestratorOrOptions instanceof Object && "orchestrator" in orchestratorOrOptions
208
+ ? orchestratorOrOptions
209
+ : { orchestrator: orchestratorOrOptions };
210
+
211
+ const { orchestrator, db, sdk } = options;
212
+
213
+ return async (input: { event: { type: string; [key: string]: unknown } }): Promise<void> => {
214
+ try {
215
+ const { event } = input;
216
+ const properties = getEventProperties(event);
217
+
218
+ switch (event.type) {
219
+ case "session.error": {
220
+ const sessionId = extractSessionId(properties);
221
+ const error = extractError(properties);
222
+ if (!sessionId || !error) {
223
+ return;
224
+ }
225
+
226
+ const action = orchestrator.handleError(sessionId, error, properties);
227
+ if (action) {
228
+ executeRecoveryAction(action, sessionId, orchestrator, sdk);
229
+ }
230
+ return;
231
+ }
232
+
233
+ case "session.deleted": {
234
+ const sessionId = extractSessionId(properties);
235
+ if (!sessionId) {
236
+ return;
237
+ }
238
+
239
+ orchestrator.reset(sessionId);
240
+ if (db) {
241
+ try {
242
+ clearRecoveryState(db, sessionId);
243
+ } catch (error: unknown) {
244
+ logger.warn("Failed to clear persisted recovery state on session delete", {
245
+ sessionId,
246
+ error: error instanceof Error ? error.message : String(error),
247
+ });
248
+ }
249
+ }
250
+ logger.info("Recovery state cleared", { sessionId });
251
+ return;
252
+ }
253
+
254
+ default:
255
+ return;
256
+ }
257
+ } catch (error: unknown) {
258
+ logger.warn("Recovery event handling failed", {
259
+ error: error instanceof Error ? error.message : String(error),
260
+ });
261
+ }
262
+ };
263
+ }
@@ -0,0 +1,20 @@
1
+ export { classifyError } from "./classifier";
2
+ export { createRecoveryEventHandler } from "./event-handler";
3
+ export {
4
+ createRecoveryOrchestratorWithDb,
5
+ getDefaultRecoveryOrchestrator,
6
+ RecoveryOrchestrator,
7
+ } from "./orchestrator";
8
+ export {
9
+ clearRecoveryState,
10
+ listRecoveryStates,
11
+ loadRecoveryState,
12
+ saveRecoveryState,
13
+ } from "./persistence";
14
+ export { getStrategy, type RecoveryStrategyResolver } from "./strategies";
15
+ export type {
16
+ ClassificationResult,
17
+ RecoveryActionEnvelope,
18
+ RecoveryAttempt,
19
+ RecoveryState,
20
+ } from "./types";
@@ -0,0 +1,180 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { getLogger } from "../logging/domains";
3
+ import type { Logger } from "../logging/types";
4
+ import type { RecoveryAction } from "../types/recovery";
5
+ import { classifyError } from "./classifier";
6
+ import { clearRecoveryState, loadRecoveryState, saveRecoveryState } from "./persistence";
7
+ import { getStrategy } from "./strategies";
8
+ import type { RecoveryAttempt, RecoveryState } from "./types";
9
+
10
+ interface RecoveryOrchestratorOptions {
11
+ readonly maxAttempts?: number;
12
+ readonly logger?: Logger;
13
+ readonly db?: Database;
14
+ }
15
+
16
+ function getErrorMessage(error: Error | string): string {
17
+ return typeof error === "string" ? error : error.message;
18
+ }
19
+
20
+ function cloneState(state: RecoveryState): RecoveryState {
21
+ return Object.freeze({
22
+ ...state,
23
+ attempts: Object.freeze([...state.attempts]),
24
+ });
25
+ }
26
+
27
+ export class RecoveryOrchestrator {
28
+ private readonly maxAttempts: number;
29
+ private readonly logger: Logger;
30
+ private readonly db: Database | null;
31
+ private readonly states = new Map<string, RecoveryState>();
32
+
33
+ constructor(options: RecoveryOrchestratorOptions = {}) {
34
+ this.maxAttempts = options.maxAttempts ?? 3;
35
+ this.logger = options.logger ?? getLogger("recovery", "orchestrator");
36
+ this.db = options.db ?? null;
37
+ }
38
+
39
+ handleError(
40
+ sessionId: string,
41
+ error: Error | string,
42
+ context?: Record<string, unknown>,
43
+ ): RecoveryAction | null {
44
+ const classification = classifyError(error, context);
45
+ if (!classification.isRecoverable) {
46
+ this.logger.warn("Recovery skipped for non-recoverable error", {
47
+ sessionId,
48
+ category: classification.category,
49
+ });
50
+ return null;
51
+ }
52
+
53
+ const previousState = this.states.get(sessionId) ??
54
+ this.loadFromDb(sessionId) ?? {
55
+ sessionId,
56
+ attempts: Object.freeze([]),
57
+ currentStrategy: null,
58
+ maxAttempts: this.maxAttempts,
59
+ isRecovering: false,
60
+ lastError: null,
61
+ };
62
+
63
+ if (previousState.attempts.length >= previousState.maxAttempts) {
64
+ this.logger.warn("Recovery attempt limit reached", {
65
+ sessionId,
66
+ attempts: previousState.attempts.length,
67
+ maxAttempts: previousState.maxAttempts,
68
+ });
69
+ return null;
70
+ }
71
+
72
+ const action = getStrategy(classification.category)(previousState);
73
+ const attempt: RecoveryAttempt = Object.freeze({
74
+ attemptNumber: previousState.attempts.length + 1,
75
+ strategy: action.strategy,
76
+ errorCategory: classification.category,
77
+ timestamp: new Date().toISOString(),
78
+ success: false,
79
+ error: getErrorMessage(error),
80
+ });
81
+
82
+ const nextState = cloneState({
83
+ sessionId,
84
+ attempts: Object.freeze([...previousState.attempts, attempt]),
85
+ currentStrategy: action.strategy,
86
+ maxAttempts: previousState.maxAttempts,
87
+ isRecovering: true,
88
+ lastError: getErrorMessage(error),
89
+ });
90
+
91
+ this.states.set(sessionId, nextState);
92
+ this.persistToDb(nextState);
93
+ return action;
94
+ }
95
+
96
+ getState(sessionId: string): RecoveryState | null {
97
+ const state = this.states.get(sessionId);
98
+ return state ? cloneState(state) : null;
99
+ }
100
+
101
+ reset(sessionId: string): void {
102
+ this.states.delete(sessionId);
103
+ this.clearFromDb(sessionId);
104
+ }
105
+
106
+ getHistory(sessionId: string): readonly RecoveryAttempt[] {
107
+ return this.getState(sessionId)?.attempts ?? Object.freeze([]);
108
+ }
109
+
110
+ recordResult(sessionId: string, success: boolean): void {
111
+ const state = this.states.get(sessionId);
112
+ if (!state || state.attempts.length === 0) {
113
+ return;
114
+ }
115
+
116
+ const lastAttempt = state.attempts[state.attempts.length - 1];
117
+ const updatedAttempt: RecoveryAttempt = Object.freeze({
118
+ ...lastAttempt,
119
+ success,
120
+ });
121
+ const attempts = Object.freeze([...state.attempts.slice(0, -1), updatedAttempt]);
122
+ const nextState = cloneState({
123
+ ...state,
124
+ attempts,
125
+ currentStrategy: success ? null : state.currentStrategy,
126
+ isRecovering: false,
127
+ lastError: success ? null : state.lastError,
128
+ });
129
+ this.states.set(sessionId, nextState);
130
+ this.persistToDb(nextState);
131
+ }
132
+
133
+ private loadFromDb(sessionId: string): RecoveryState | null {
134
+ if (!this.db) return null;
135
+ try {
136
+ return loadRecoveryState(this.db, sessionId);
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ private persistToDb(state: RecoveryState): void {
143
+ if (!this.db) return;
144
+ try {
145
+ saveRecoveryState(this.db, state);
146
+ } catch (error) {
147
+ this.logger.warn("Failed to persist recovery state", {
148
+ sessionId: state.sessionId,
149
+ error: error instanceof Error ? error.message : String(error),
150
+ });
151
+ }
152
+ }
153
+
154
+ private clearFromDb(sessionId: string): void {
155
+ if (!this.db) return;
156
+ try {
157
+ clearRecoveryState(this.db, sessionId);
158
+ } catch (error) {
159
+ this.logger.warn("Failed to clear recovery state", {
160
+ sessionId,
161
+ error: error instanceof Error ? error.message : String(error),
162
+ });
163
+ }
164
+ }
165
+ }
166
+
167
+ let defaultRecoveryOrchestrator: RecoveryOrchestrator | null = null;
168
+
169
+ export function getDefaultRecoveryOrchestrator(): RecoveryOrchestrator {
170
+ if (defaultRecoveryOrchestrator) {
171
+ return defaultRecoveryOrchestrator;
172
+ }
173
+
174
+ defaultRecoveryOrchestrator = new RecoveryOrchestrator();
175
+ return defaultRecoveryOrchestrator;
176
+ }
177
+
178
+ export function createRecoveryOrchestratorWithDb(db: Database): RecoveryOrchestrator {
179
+ return new RecoveryOrchestrator({ db });
180
+ }