@dmsdc-ai/aigentry-deliberation 0.0.1

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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * DegradationStateMachine — 4-stage graceful degradation for BrowserControlPort
3
+ *
4
+ * States: HEALTHY → RETRYING → REBINDING → RELOADING → FALLBACK → FAILED
5
+ * Time budget (60s SLO): S1(12s) + S2(8s) + S3(18s) + S4(22s spare)
6
+ */
7
+
8
+ const STATES = {
9
+ HEALTHY: "HEALTHY",
10
+ RETRYING: "RETRYING",
11
+ REBINDING: "REBINDING",
12
+ RELOADING: "RELOADING",
13
+ FALLBACK: "FALLBACK",
14
+ FAILED: "FAILED",
15
+ };
16
+
17
+ const STAGE_BUDGETS = {
18
+ RETRYING: { maxAttempts: 2, backoffMs: [2000, 4000], budgetMs: 12000 },
19
+ REBINDING: { maxAttempts: 1, budgetMs: 8000 },
20
+ RELOADING: { maxAttempts: 1, budgetMs: 18000 },
21
+ FALLBACK: { budgetMs: 22000 },
22
+ };
23
+
24
+ const ERROR_CODES = {
25
+ BIND_FAILED: { category: "transient", domain: "browser", retryable: true },
26
+ SEND_FAILED: { category: "transient", domain: "transport", retryable: true },
27
+ TIMEOUT: { category: "transient", domain: "transport", retryable: true },
28
+ DOM_CHANGED: { category: "transient", domain: "dom", retryable: true },
29
+ SESSION_EXPIRED: { category: "permanent", domain: "session", retryable: false },
30
+ TAB_CLOSED: { category: "transient", domain: "browser", retryable: true },
31
+ NETWORK_DISCONNECTED: { category: "transient", domain: "transport", retryable: true },
32
+ MCP_CHANNEL_CLOSED: { category: "transient", domain: "transport", retryable: true },
33
+ BROWSER_CRASHED: { category: "transient", domain: "browser", retryable: true },
34
+ INVALID_SELECTOR_CONFIG: { category: "permanent", domain: "dom", retryable: false },
35
+ };
36
+
37
+ function makeResult(ok, data, error) {
38
+ if (ok) return { ok: true, data: data ?? null };
39
+ const meta = ERROR_CODES[error?.code] || ERROR_CODES.TIMEOUT;
40
+ return {
41
+ ok: false,
42
+ error: {
43
+ code: error?.code || "UNKNOWN",
44
+ category: meta.category,
45
+ domain: meta.domain,
46
+ message: error?.message || "Unknown error",
47
+ retryable: meta.retryable,
48
+ },
49
+ };
50
+ }
51
+
52
+ class DegradationStateMachine {
53
+ constructor({ onRetry, onRebind, onReload, onFallback, skipEnabled = false } = {}) {
54
+ this.state = STATES.HEALTHY;
55
+ this.stageAttempts = { RETRYING: 0, REBINDING: 0, RELOADING: 0 };
56
+ this.startTime = null;
57
+ this.lastError = null;
58
+ this.skipEnabled = skipEnabled;
59
+
60
+ // Callbacks for each stage action
61
+ this._onRetry = onRetry || (() => makeResult(false, null, { code: "SEND_FAILED", message: "retry not implemented" }));
62
+ this._onRebind = onRebind || (() => makeResult(false, null, { code: "DOM_CHANGED", message: "rebind not implemented" }));
63
+ this._onReload = onReload || (() => makeResult(false, null, { code: "TAB_CLOSED", message: "reload not implemented" }));
64
+ this._onFallback = onFallback || (() => makeResult(false, null, { code: "TIMEOUT", message: "fallback not implemented" }));
65
+ }
66
+
67
+ reset() {
68
+ this.state = STATES.HEALTHY;
69
+ this.stageAttempts = { RETRYING: 0, REBINDING: 0, RELOADING: 0 };
70
+ this.startTime = null;
71
+ this.lastError = null;
72
+ }
73
+
74
+ get elapsedMs() {
75
+ return this.startTime ? Date.now() - this.startTime : 0;
76
+ }
77
+
78
+ get totalBudgetMs() {
79
+ return 60000;
80
+ }
81
+
82
+ get isTerminal() {
83
+ return this.state === STATES.FAILED || this.state === STATES.FALLBACK;
84
+ }
85
+
86
+ /**
87
+ * Execute a turn with full degradation pipeline.
88
+ * @param {Function} primaryAction - async () => Result. The main action to attempt.
89
+ * @returns {Result} Final result after all degradation attempts.
90
+ */
91
+ async execute(primaryAction) {
92
+ this.startTime = Date.now();
93
+ this.state = STATES.HEALTHY;
94
+ this.stageAttempts = { RETRYING: 0, REBINDING: 0, RELOADING: 0 };
95
+
96
+ // Stage 0: Primary attempt
97
+ const primaryResult = await this._timed(primaryAction);
98
+ if (primaryResult.ok) return primaryResult;
99
+ this.lastError = primaryResult.error;
100
+
101
+ // Check if permanent error — skip to FAILED
102
+ if (primaryResult.error && !primaryResult.error.retryable) {
103
+ this.state = STATES.FAILED;
104
+ return primaryResult;
105
+ }
106
+
107
+ // Stage 1: Retry with backoff
108
+ this.state = STATES.RETRYING;
109
+ const retryResult = await this._stageRetry(primaryAction);
110
+ if (retryResult.ok) { this.state = STATES.HEALTHY; return retryResult; }
111
+ if (this._budgetExceeded()) return this._toFallback(retryResult);
112
+
113
+ // Stage 2: Rebind (DOM re-scan)
114
+ this.state = STATES.REBINDING;
115
+ const rebindResult = await this._stageRebind();
116
+ if (rebindResult.ok) {
117
+ // Rebind succeeded, retry primary once more
118
+ const afterRebind = await this._timed(primaryAction);
119
+ if (afterRebind.ok) { this.state = STATES.HEALTHY; return afterRebind; }
120
+ }
121
+ if (this._budgetExceeded()) return this._toFallback(rebindResult);
122
+
123
+ // Stage 3: Reload/Reopen
124
+ this.state = STATES.RELOADING;
125
+ const reloadResult = await this._stageReload();
126
+ if (reloadResult.ok) {
127
+ // After reload, retry primary once (auto-resend with turn_id idempotency)
128
+ const afterReload = await this._timed(primaryAction);
129
+ if (afterReload.ok) { this.state = STATES.HEALTHY; return afterReload; }
130
+ }
131
+ if (this._budgetExceeded()) return this._toFallback(reloadResult);
132
+
133
+ // Stage 4: Fallback
134
+ return this._toFallback(reloadResult);
135
+ }
136
+
137
+ async _stageRetry(action) {
138
+ const budget = STAGE_BUDGETS.RETRYING;
139
+ let lastResult = null;
140
+ for (let i = 0; i < budget.maxAttempts; i++) {
141
+ if (this._budgetExceeded()) break;
142
+ const delay = budget.backoffMs[i] || budget.backoffMs[budget.backoffMs.length - 1];
143
+ await this._sleep(delay);
144
+ this.stageAttempts.RETRYING++;
145
+ lastResult = await this._timed(action);
146
+ if (lastResult.ok) return lastResult;
147
+ this.lastError = lastResult.error;
148
+ }
149
+ return lastResult || makeResult(false, null, { code: "TIMEOUT", message: "retry exhausted" });
150
+ }
151
+
152
+ async _stageRebind() {
153
+ if (this._budgetExceeded()) return makeResult(false, null, { code: "TIMEOUT", message: "budget exceeded before rebind" });
154
+ this.stageAttempts.REBINDING++;
155
+ return this._timed(this._onRebind);
156
+ }
157
+
158
+ async _stageReload() {
159
+ if (this._budgetExceeded()) return makeResult(false, null, { code: "TIMEOUT", message: "budget exceeded before reload" });
160
+ this.stageAttempts.RELOADING++;
161
+ return this._timed(this._onReload);
162
+ }
163
+
164
+ async _toFallback(lastResult) {
165
+ this.state = STATES.FALLBACK;
166
+ const fallbackResult = await this._onFallback(lastResult);
167
+ if (!fallbackResult?.ok && !this.skipEnabled) {
168
+ this.state = STATES.FAILED;
169
+ }
170
+ return fallbackResult || makeResult(false, null, {
171
+ code: "TIMEOUT",
172
+ message: `All degradation stages exhausted (elapsed: ${this.elapsedMs}ms)`,
173
+ });
174
+ }
175
+
176
+ _budgetExceeded() {
177
+ return this.elapsedMs >= this.totalBudgetMs;
178
+ }
179
+
180
+ async _timed(fn) {
181
+ try {
182
+ return await fn();
183
+ } catch (err) {
184
+ return makeResult(false, null, {
185
+ code: err.code || "UNKNOWN",
186
+ message: err.message || String(err),
187
+ });
188
+ }
189
+ }
190
+
191
+ _sleep(ms) {
192
+ return new Promise(resolve => setTimeout(resolve, ms));
193
+ }
194
+
195
+ toJSON() {
196
+ return {
197
+ state: this.state,
198
+ elapsedMs: this.elapsedMs,
199
+ stageAttempts: { ...this.stageAttempts },
200
+ lastError: this.lastError,
201
+ skipEnabled: this.skipEnabled,
202
+ };
203
+ }
204
+ }
205
+
206
+ export { DegradationStateMachine, STATES, STAGE_BUDGETS, ERROR_CODES, makeResult };