@awak-app/simy-cli 0.6.10 → 0.6.11

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.
@@ -6,81 +6,159 @@ import { join, relative, isAbsolute, sep } from 'node:path';
6
6
  const HOUR = 3_600_000;
7
7
  export const unavailableStatistics = () => ({ state: 'unavailable', hours: [], activity: [] });
8
8
 
9
- // Only usage envelopes and timestamps leave this reader, never message text.
10
- // Work time is an estimate from recorded activity, not a provider billing metric.
11
- export function transcriptStatistics(text, provider, session, { now = Date.now(), truncated = false } = {}) {
12
- const events = [], usage = new Map();
13
- let cumulative = 0, missing = truncated, matched = false;
14
- for (const line of text.split('\n')) {
15
- if (!line.trim()) continue;
9
+ const READ_BYTES = 1024 * 1024;
10
+ const MAX_LINE_BYTES = 4 * READ_BYTES;
11
+ const WINDOW_MS = 7 * 24 * HOUR;
12
+ // One selected transcript only; no raw messages or on-disk cache. Identity and
13
+ // ctime prevent reuse after replacement/truncation even when size is unchanged.
14
+ let cachedTranscript;
15
+ const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs].join(':');
16
+
17
+ // Only usage envelopes and timestamps survive parsing, never message text.
18
+ // The reverse reducer attributes each cumulative difference to the newer row,
19
+ // retaining an older baseline even when it lies outside the displayed week.
20
+ class StatisticsAccumulator {
21
+ constructor(provider, session, now, reverse = false) {
22
+ Object.assign(this, { provider, session, scanNow: now, reverse, events: [], usage: new Map(), cumulative: 0,
23
+ pending: null, earliest: Infinity, missing: false, matched: false, wrongSession: false });
24
+ }
25
+ recordUsage(at, total, delta) {
26
+ if (at < this.scanNow - WINDOW_MS) return;
27
+ const key = `codex:${at}:${total}`;
28
+ if (delta > 0 || !this.usage.has(key)) this.usage.set(key, { at, tokens: delta });
29
+ }
30
+ add(line) {
31
+ if (!line.trim()) return;
16
32
  let row;
17
- try { row = JSON.parse(line); } catch { missing = true; continue; }
18
- if (provider === 'codex' && row.type === 'session_meta') {
19
- if (row.payload?.id !== session) return unavailableStatistics();
20
- matched = true;
33
+ try { row = JSON.parse(line); } catch { this.missing = true; return; }
34
+ if (!row || typeof row !== 'object' || Array.isArray(row)) { this.missing = true; return; }
35
+ if (this.provider === 'codex' && row.type === 'session_meta') {
36
+ if (row.payload?.id !== this.session) this.wrongSession = true;
37
+ else this.matched = true;
21
38
  }
22
- if (provider === 'claude') {
23
- if (row.sessionId !== session || row.isSidechain === true) continue;
24
- matched = true;
39
+ if (this.provider === 'claude') {
40
+ if (row.sessionId !== this.session || row.isSidechain === true) return;
41
+ this.matched = true;
25
42
  }
26
43
  const at = Date.parse(row.timestamp);
27
- if (!Number.isFinite(at) || at > now) continue;
28
- const active = provider === 'claude' ? ['user', 'assistant'].includes(row.type) && !row.isMeta
44
+ if (!Number.isFinite(at)) return;
45
+ const inWindow = at >= this.scanNow - WINDOW_MS;
46
+ const active = this.provider === 'claude' ? ['user', 'assistant'].includes(row.type) && !row.isMeta
29
47
  : ['response_item', 'event_msg'].includes(row.type) && row.payload?.type !== 'token_count';
30
- if (active) events.push(at);
31
- if (provider === 'codex' && row.type === 'event_msg' && row.payload?.type === 'token_count') {
48
+ if (active) {
49
+ this.earliest = Math.min(this.earliest, at);
50
+ if (inWindow) this.events.push(at);
51
+ }
52
+ if (this.provider === 'codex' && row.type === 'event_msg' && row.payload?.type === 'token_count') {
32
53
  const total = row.payload.info?.total_token_usage?.total_tokens;
33
- if (!Number.isSafeInteger(total) || total < 0) continue;
34
- if (total < cumulative) { missing = true; cumulative = total; continue; }
35
- const delta = total - cumulative; cumulative = total;
36
- const key = `codex:${at}:${total}`;
37
- if (delta > 0 || !usage.has(key)) usage.set(key, { at, tokens: delta });
38
- } else if (provider === 'claude' && row.type === 'assistant' && row.message?.id) {
54
+ if (!Number.isSafeInteger(total) || total < 0) return;
55
+ this.earliest = Math.min(this.earliest, at);
56
+ if (this.reverse) {
57
+ if (this.pending) {
58
+ const delta = this.pending.total - total;
59
+ if (delta < 0) this.missing = true;
60
+ else this.recordUsage(this.pending.at, this.pending.total, delta);
61
+ }
62
+ this.pending = { at, total };
63
+ } else {
64
+ if (at > this.scanNow) return;
65
+ if (total < this.cumulative) { this.missing = true; this.cumulative = total; return; }
66
+ this.recordUsage(at, total, total - this.cumulative);
67
+ this.cumulative = total;
68
+ }
69
+ } else if (this.provider === 'claude' && row.type === 'assistant' && row.message?.id && (inWindow || !this.reverse || this.usage.has(row.message.id))) {
39
70
  const u = row.message.usage;
40
- if (!u || ![u.input_tokens, u.output_tokens].every(x => Number.isSafeInteger(x) && x >= 0)) continue;
41
- // Cached input is separate in Claude. Count it once with uncached input.
42
- if (![u.cache_creation_input_tokens ?? 0, u.cache_read_input_tokens ?? 0].every(x => Number.isSafeInteger(x) && x >= 0)) continue;
71
+ if (!u || ![u.input_tokens, u.output_tokens, u.cache_creation_input_tokens ?? 0, u.cache_read_input_tokens ?? 0]
72
+ .every(x => Number.isSafeInteger(x) && x >= 0)) return;
43
73
  const tokens = u.input_tokens + u.output_tokens + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
44
- if (!Number.isSafeInteger(tokens) || tokens < 0) continue;
45
- const previous = usage.get(row.message.id);
46
- if (!previous || tokens > previous.tokens) usage.set(row.message.id, { at, tokens });
74
+ if (!Number.isSafeInteger(tokens)) return;
75
+ const previous = this.usage.get(row.message.id);
76
+ if (!previous || tokens > previous.tokens || tokens === previous.tokens && at < previous.at) this.usage.set(row.message.id, { at, tokens });
47
77
  }
48
78
  }
49
- if (!matched) return unavailableStatistics();
50
- const timestamps = [...events, ...[...usage.values()].map(x => x.at)].sort((a, b) => a - b);
51
- if (!timestamps.length) return unavailableStatistics();
52
- const start = Math.max(timestamps[0], now - 7 * 24 * HOUR);
53
- const end = now;
54
- const hours = new Map();
55
- for (let at = Math.floor(start / HOUR) * HOUR; at <= end; at += HOUR) hours.set(at, { at, tokens: null });
56
- for (const item of usage.values()) {
57
- if (item.at < start || item.at > end) continue;
58
- const bucket = hours.get(Math.floor(item.at / HOUR) * HOUR);
59
- bucket.tokens = (bucket.tokens ?? 0) + item.tokens;
79
+ finish(now, incomplete = false, coverage = {}) {
80
+ if (!this.matched || this.wrongSession || this.earliest > now) return unavailableStatistics();
81
+ const missing = this.missing || incomplete;
82
+ const start = Math.max(this.earliest, now - WINDOW_MS), end = now;
83
+ const hours = new Map();
84
+ for (let at = Math.floor(start / HOUR) * HOUR; at <= end; at += HOUR) hours.set(at, { at, tokens: null });
85
+ const usage = [...this.usage.values()];
86
+ if (this.reverse && this.pending) usage.push({ at: this.pending.at, tokens: this.pending.total });
87
+ for (const item of usage) {
88
+ if (item.at < start || item.at > end) continue;
89
+ const bucket = hours.get(Math.floor(item.at / HOUR) * HOUR);
90
+ bucket.tokens = (bucket.tokens ?? 0) + item.tokens;
91
+ }
92
+ const activity = [];
93
+ for (const at of this.events.filter(x => x >= start && x <= end).sort((a, b) => a - b)) {
94
+ const previous = activity.at(-1);
95
+ if (previous && at - previous.end <= 5 * 60_000) previous.end = at;
96
+ else activity.push({ start: at, end: at });
97
+ }
98
+ const activeMs = activity.reduce((total, x) => total + x.end - x.start, 0);
99
+ const elapsedMs = end - start;
100
+ return { state: missing ? 'partial' : 'ready', start, end, observedAt: now, hours: [...hours.values()], activity,
101
+ tokens: [...hours.values()].some(h => h.tokens !== null) ? [...hours.values()].reduce((sum, h) => sum + (h.tokens || 0), 0) : null,
102
+ activeMs, idleMs: missing ? null : Math.max(0, elapsedMs - activeMs),
103
+ utilization: missing || !elapsedMs ? null : activeMs / elapsedMs,
104
+ coverage: { ...coverage, complete: !missing, scope: 'rolling_7_days' },
105
+ activityMethod: 'recorded_events_5m_gap', includesCachedInput: true };
60
106
  }
61
- const activity = [];
62
- for (const at of events.filter(x => x >= start && x <= end).sort((a, b) => a - b)) {
63
- const previous = activity.at(-1);
64
- if (previous && at - previous.end <= 5 * 60_000) previous.end = at;
65
- else activity.push({ start: at, end: at });
107
+ }
108
+
109
+ export function transcriptStatistics(text, provider, session, { now = Date.now(), truncated = false } = {}) {
110
+ const accumulator = new StatisticsAccumulator(provider, session, now);
111
+ for (const line of text.split('\n')) accumulator.add(line);
112
+ return accumulator.finish(now, truncated);
113
+ }
114
+
115
+ async function readReverse(handle, size, accumulator) {
116
+ let position = size, carry = Buffer.alloc(0), skipping = false, bytesRead = 0;
117
+ while (position > 0) {
118
+ const length = Math.min(position, READ_BYTES); position -= length;
119
+ const buffer = Buffer.allocUnsafe(length);
120
+ let filled = 0;
121
+ while (filled < length) {
122
+ const read = await handle.read(buffer, filled, length - filled, position + filled);
123
+ if (!read.bytesRead) break;
124
+ filled += read.bytesRead;
125
+ }
126
+ bytesRead += filled;
127
+ if (filled !== length) accumulator.missing = true;
128
+ const data = Buffer.concat([buffer.subarray(0, filled), carry]);
129
+ let end = data.length;
130
+ for (let index = data.lastIndexOf(10); index >= 0; index = data.lastIndexOf(10, index - 1)) {
131
+ const line = data.subarray(index + 1, end);
132
+ if (!skipping && line.length <= MAX_LINE_BYTES) accumulator.add(line.toString('utf8'));
133
+ else accumulator.missing = true;
134
+ skipping = false; end = index;
135
+ if (index === 0) break;
136
+ }
137
+ if (end > MAX_LINE_BYTES || skipping) { carry = Buffer.alloc(0); skipping = true; accumulator.missing = true; }
138
+ else carry = Buffer.from(data.subarray(0, end));
66
139
  }
67
- const activeMs = activity.reduce((total, x) => total + x.end - x.start, 0);
68
- const elapsedMs = end - start;
69
- return { state: missing ? 'partial' : 'ready', start, end, observedAt: now, hours: [...hours.values()], activity,
70
- tokens: [...hours.values()].some(h => h.tokens !== null) ? [...hours.values()].reduce((sum, h) => sum + (h.tokens || 0), 0) : null,
71
- activeMs, idleMs: Math.max(0, elapsedMs - activeMs), utilization: elapsedMs ? activeMs / elapsedMs : null,
72
- activityMethod: 'recorded_events_5m_gap', includesCachedInput: true };
140
+ if (!skipping) accumulator.add(carry.toString('utf8'));
141
+ return bytesRead;
73
142
  }
74
143
 
75
- export async function statisticsFromHandle(handle, info, provider, session, options) {
76
- const maximum = 32 * 1024 * 1024;
77
- // Keep the prefix for identity and cumulative usage. Never invent a baseline
78
- // from a truncated tail or claim the unobserved remainder as zero.
79
- const buffer = Buffer.alloc(Math.min(info.size, maximum));
80
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
144
+ export async function statisticsFromHandle(handle, info, provider, session, { now = Date.now(), ...options } = {}) {
145
+ const key = `${provider}:${session}:${fingerprint(info)}`;
146
+ // Rebuild time-dependent buckets from cached envelopes, never cache wall-clock
147
+ // idle time. A backwards clock or a new file fingerprint requires a new scan.
148
+ if (cachedTranscript?.key === key && now >= cachedTranscript.accumulator.scanNow) {
149
+ const cached = cachedTranscript;
150
+ const changed = fingerprint(await handle.stat()) !== fingerprint(info);
151
+ if (changed) cachedTranscript = undefined;
152
+ return cached.accumulator.finish(now, changed || options.truncated, cached.coverage);
153
+ }
154
+ const accumulator = new StatisticsAccumulator(provider, session, now, true);
155
+ const bytesRead = await readReverse(handle, info.size, accumulator);
81
156
  const after = await handle.stat();
82
- return transcriptStatistics(buffer.subarray(0, bytesRead).toString('utf8'), provider, session,
83
- { ...options, truncated: info.size > maximum || after.size !== info.size || after.mtimeMs !== info.mtimeMs });
157
+ const changed = fingerprint(after) !== fingerprint(info);
158
+ const coverage = { bytesRead, totalBytes: info.size };
159
+ if (!changed && !accumulator.missing && !options.truncated) cachedTranscript = { key, accumulator, coverage };
160
+ else cachedTranscript = undefined;
161
+ return accumulator.finish(now, changed || options.truncated, coverage);
84
162
  }
85
163
 
86
164
  export async function readCodexStatistics(client, candidate, { home = process.env.CODEX_HOME || join(homedir(), '.codex'), ...options } = {}) {
@@ -0,0 +1,105 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { compareVersions } from "../backend-executable.js";
5
+ import { CLI_VERSION } from "../cli-contract.js";
6
+ import { defaultSessionRoot } from "../session-store.js";
7
+
8
+ export const SQM_FAILURE_PAUSE_THRESHOLD = 6;
9
+ const scopeKey = scope => JSON.stringify([scope?.webOrigin, scope?.accountId, scope?.organizationId]);
10
+ const validVersion = value => typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value);
11
+
12
+ export function createSqmFailureCircuitStore({ root = defaultSessionRoot() } = {}) {
13
+ const directory = join(root, "sqm-failure-circuit");
14
+ const path = scope => {
15
+ if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) throw new Error("SQM failure state requires an organization scope.");
16
+ return join(directory, `${createHash("sha256").update(scopeKey(scope)).digest("hex")}.json`);
17
+ };
18
+ return {
19
+ async read(scope) {
20
+ try {
21
+ const state = JSON.parse(await readFile(path(scope), "utf8"));
22
+ if (!validVersion(state.version) || !Number.isInteger(state.failures) || state.failures < 0 || state.failures > SQM_FAILURE_PAUSE_THRESHOLD || (state.inFlight !== undefined && typeof state.inFlight !== "boolean"))
23
+ throw new Error("Invalid saved SQM failure state.");
24
+ return state;
25
+ } catch (error) { if (error.code === "ENOENT") return null; throw error; }
26
+ },
27
+ async write(scope, state) {
28
+ const target = path(scope), temporary = `${target}.${randomUUID()}.tmp`;
29
+ await mkdir(directory, { recursive: true, mode: 0o700 });
30
+ try {
31
+ await writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
32
+ await rename(temporary, target);
33
+ } finally { await rm(temporary, { force: true }); }
34
+ },
35
+ };
36
+ }
37
+
38
+ // One counter per organization on this machine, shared by both local engines.
39
+ // Neither reconnecting nor an empty claim demonstrates successful analysis.
40
+ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null } = {}) {
41
+ if (!validVersion(version)) throw new Error("SQM failure circuit requires a SIMY version.");
42
+ const states = new Map();
43
+ const unreadableScopes = new Set();
44
+ const save = async (scope, state) => {
45
+ try { await store?.write(scope, { version: state.version, failures: state.failures, inFlight: state.inFlight === true }); state.storageError = false; }
46
+ catch (error) { state.storageError = true; throw error; }
47
+ };
48
+ const load = async scope => {
49
+ const key = scopeKey(scope);
50
+ let state = states.get(key);
51
+ if (!state) {
52
+ let saved;
53
+ try { saved = await store?.read(scope); unreadableScopes.delete(key); }
54
+ catch (error) { unreadableScopes.add(key); throw error; }
55
+ state = saved ? { version: saved.version, failures: saved.failures, inFlight: saved.inFlight === true } : { version, failures: 0, inFlight: false };
56
+ states.set(key, state);
57
+ // An unfinished review from this same version means the last host exited
58
+ // without a result or graceful cancellation. Count it once on restart.
59
+ if (state.inFlight && compareVersions(version, state.version) <= 0) {
60
+ state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
61
+ state.inFlight = false;
62
+ await save(scope, state);
63
+ }
64
+ }
65
+ if (compareVersions(version, state.version) > 0) {
66
+ state.version = version;
67
+ state.failures = 0;
68
+ state.inFlight = false;
69
+ await save(scope, state);
70
+ } else if (state.storageError) await save(scope, state);
71
+ return state;
72
+ };
73
+ return {
74
+ async canClaim(scope) { return (await load(scope)).failures < SQM_FAILURE_PAUSE_THRESHOLD; },
75
+ async begin(scope) {
76
+ const state = await load(scope);
77
+ state.inFlight = true;
78
+ await save(scope, state);
79
+ },
80
+ async cancel(scope) {
81
+ const state = await load(scope);
82
+ state.inFlight = false;
83
+ await save(scope, state);
84
+ },
85
+ async failure(scope) {
86
+ const state = await load(scope);
87
+ state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
88
+ state.inFlight = false;
89
+ await save(scope, state);
90
+ },
91
+ async success(scope) {
92
+ const state = await load(scope);
93
+ state.failures = 0;
94
+ state.inFlight = false;
95
+ await save(scope, state);
96
+ },
97
+ snapshot(scope) {
98
+ const state = states.get(scopeKey(scope));
99
+ return { consecutive_failures: state?.failures || 0,
100
+ paused_until_update: (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD,
101
+ paused_at_version: (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD ? state.version : null,
102
+ storage_unavailable: state?.storageError === true || unreadableScopes.has(scopeKey(scope)) };
103
+ },
104
+ };
105
+ }
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
7
  import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
8
+ import { createSqmFailureCircuit } from "./failure-circuit.js";
8
9
  import { inspectProviderStatus } from "../desktop-status.js";
9
10
  import { inspectDesktopExecutor } from "../desktop-executor.js";
10
11
  import { safeProviderDiagnostic, PROVIDER_FAILURE_HINTS } from "./provider-diagnostics.js";
@@ -109,6 +110,7 @@ export function createSqmLocalReviewWorker({
109
110
  now = () => Date.now(),
110
111
  providerFailureCooldownMs = 5 * 60_000,
111
112
  getRecoveryKey = () => null,
113
+ failureCircuit = createSqmFailureCircuit(),
112
114
  } = {}) {
113
115
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
114
116
  let activeJobId = null;
@@ -187,7 +189,6 @@ export function createSqmLocalReviewWorker({
187
189
  kind: "analysis", state: "running", provider, startedAt: new Date().toISOString(), webUrl: analysisTaskUrl(workspaceUrl, claim.job_id) });
188
190
  historyScopes.set(history[0], JSON.stringify(historyScope));
189
191
  history.splice(50);
190
- await persist(historyScope, history[0]);
191
192
  onChange();
192
193
  let heartbeatTimer = null;
193
194
  const heartbeat = () => {
@@ -203,6 +204,8 @@ export function createSqmLocalReviewWorker({
203
204
  const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
204
205
  ? { reported_executor_provider: review.provider || selectedProvider } : {};
205
206
  try {
207
+ await failureCircuit.begin(historyScope);
208
+ await persist(historyScope, history[0]);
206
209
  const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => request({
207
210
  action: "advance",
208
211
  ...leaseBody(claim, provider),
@@ -250,7 +253,11 @@ export function createSqmLocalReviewWorker({
250
253
  return {
251
254
  snapshot() {
252
255
  const pause = currentPause();
253
- return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
256
+ const circuit = failureCircuit.snapshot(getHistoryScope());
257
+ return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended,
258
+ paused: pausedEnvironment !== null || circuit.paused_until_update || circuit.storage_unavailable,
259
+ pause_reason: circuit.paused_until_update ? "consecutive_failures_until_update" : circuit.storage_unavailable ? "failure_state_unavailable" : pauseReason,
260
+ ...circuit, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
254
261
  ...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
255
262
  },
256
263
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
@@ -258,7 +265,7 @@ export function createSqmLocalReviewWorker({
258
265
  const item = visibleHistory().find(item => item.key === key);
259
266
  return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
260
267
  },
261
- retry() { if (closed || suspended || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
268
+ retry() { if (closed || suspended || pollPromise || failureCircuit.snapshot(getHistoryScope()).paused_until_update) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
262
269
  resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
263
270
  poll() {
264
271
  if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
@@ -279,6 +286,8 @@ export function createSqmLocalReviewWorker({
279
286
  const workspaceUrl = getWorkspaceUrl();
280
287
  reviewController = new AbortController();
281
288
  pollPromise = (async () => {
289
+ if (!await failureCircuit.canClaim(historyScope)) return null;
290
+ if (reviewController.signal.aborted) throw providerFailure("analysis_cancelled");
282
291
  const claim = await api.request({
283
292
  action: "claim",
284
293
  executor_provider: provider,
@@ -293,11 +302,16 @@ export function createSqmLocalReviewWorker({
293
302
  return null;
294
303
  }
295
304
  const result = await processClaim(claim, provider, historyScope, workspaceUrl);
305
+ try { await failureCircuit.success(historyScope); } catch (storageError) { onError(storageError); }
296
306
  pauses.delete(key);
297
307
  return result;
298
308
  })()
299
309
  .catch(async (error) => {
300
- if (error.code === "analysis_cancelled") return null;
310
+ if (error.code === "analysis_cancelled") {
311
+ try { await failureCircuit.cancel(historyScope); } catch (storageError) { onError(storageError); }
312
+ return null;
313
+ }
314
+ try { await failureCircuit.failure(historyScope); } catch (storageError) { onError(storageError); }
301
315
  pauseForFailure(error, provider, historyScope, recoveryKey);
302
316
  if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
303
317
  const scope = historyScope;