@awak-app/simy-cli 0.6.9 → 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.
- package/README.md +22 -0
- package/package.json +1 -1
- package/src/agent.js +29 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +110 -30
- package/src/codex-plugins/simy/hooks/workspace-lease.mjs +32 -5
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +4 -2
- package/src/coding-run-recovery.js +44 -0
- package/src/coding-run-store.js +9 -2
- package/src/delivery-hooks/command.js +5 -5
- package/src/delivery-hooks/desktop.js +5 -2
- package/src/delivery-hooks/supervisor.js +4 -3
- package/src/orchestrator/evidence.js +10 -2
- package/src/orchestrator/loop.js +99 -44
- package/src/orchestrator/risk.js +17 -2
- package/src/runner.js +63 -27
- package/src/session-supervision/analysis-manager.js +36 -9
- package/src/session-supervision/codex-app-server.js +73 -8
- package/src/session-supervision/continuation-analysis.js +49 -12
- package/src/session-supervision/conversation-context.js +7 -0
- package/src/session-supervision/index.js +71 -17
- package/src/session-supervision/statistics.js +138 -60
- package/src/sqm/failure-circuit.js +105 -0
- package/src/sqm/provider-diagnostics.js +1 -1
- package/src/sqm/review-worker.js +20 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpath } from 'node:fs/promises';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { TERMINAL as TERMINAL_RUNS, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
|
-
import { listCodexSessions, openCodexAppServer, readCodexSession, CodexAppServerError } from './codex-app-server.js';
|
|
3
|
+
import { TERMINAL as TERMINAL_RUNS, canUpgradeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
|
+
import { listCodexSessions, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
|
|
5
5
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
6
6
|
import { originalSessionTarget } from './original-session-target.js';
|
|
7
7
|
import { readCodexTitles, sessionTitle } from './session-titles.js';
|
|
@@ -55,25 +55,56 @@ async function requireClaudeChannel(record, metadata, inspectProcess) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
58
|
-
inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles } = {}) {
|
|
58
|
+
inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome } = {}) {
|
|
59
59
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
60
60
|
assertActive();
|
|
61
61
|
const records = await listRecords(root);
|
|
62
62
|
const priorities = records.filter(retainedSession);
|
|
63
63
|
let discovered = [], codexError = null, claude = [], claudeError = null;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
let archiveStates = new Map();
|
|
65
|
+
try { discovered = await withClient(openClient, async client => {
|
|
66
|
+
const active = await listCodexSessions({ client, inspectState: true, maxSessions: RECENT_SESSION_LIMIT,
|
|
67
|
+
prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
|
|
68
|
+
const ids = new Set(active.map(item => item.session));
|
|
69
|
+
archiveStates = await readCodexArchiveStates(client, records.filter(record => record.provider === 'codex' && !ids.has(record.session)), { codexHome });
|
|
70
|
+
return active;
|
|
71
|
+
}, signal); }
|
|
67
72
|
catch (error) { codexError = error?.code === 'unavailable' ? 'Codex executable unavailable.' : error?.code === 'unsupported' ? 'This Codex version lacks a verified app-server writer lock; update before enrollment.' : 'Codex conversation discovery unavailable.'; }
|
|
68
73
|
assertActive();
|
|
69
74
|
try { claude = await listClaude({ signal, limit: RECENT_SESSION_LIMIT,
|
|
70
75
|
prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
|
|
71
76
|
catch { claudeError = 'Claude conversation discovery unavailable.'; }
|
|
72
77
|
assertActive();
|
|
78
|
+
const activeCodex = new Set(discovered.map(item => item.session));
|
|
79
|
+
for (let index = 0; index < records.length; index++) {
|
|
80
|
+
assertActive();
|
|
81
|
+
const record = records[index];
|
|
82
|
+
if (record.provider !== 'codex') continue;
|
|
83
|
+
const isArchived = archiveStates.get(record.session) === true;
|
|
84
|
+
if (isArchived && record.providerArchived === true) continue;
|
|
85
|
+
if (!isArchived && !(record.providerArchived === true && (activeCodex.has(record.session) || archiveStates.get(record.session) === false))) continue;
|
|
86
|
+
records[index] = await updateRecord(root, record.provider, record.session, current => {
|
|
87
|
+
if (!current) return current;
|
|
88
|
+
current.providerArchived = isArchived;
|
|
89
|
+
if (isArchived) {
|
|
90
|
+
current.tracked = false; current.monitor = false; current.autoContinue = false;
|
|
91
|
+
current.autoContinueEpoch = randomUUID();
|
|
92
|
+
if (current.run && !TERMINAL_RUNS.has(current.run.state)) {
|
|
93
|
+
current.run.state = 'paused'; current.run.reason = 'The original provider conversation was archived.';
|
|
94
|
+
current.run.updatedAt = now;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return current;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
assertActive();
|
|
73
101
|
const byKey = new Map();
|
|
74
102
|
const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
|
|
75
|
-
for (const item of [...discovered, ...claude])
|
|
103
|
+
for (const item of [...discovered, ...claude]) {
|
|
104
|
+
if (recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
|
|
105
|
+
}
|
|
76
106
|
for (const record of records) {
|
|
107
|
+
if (record.providerArchived === true) continue;
|
|
77
108
|
const key = `${record.provider}:${record.session}`;
|
|
78
109
|
if (!byKey.has(key) && (record.observedAt || record.admission || retainedSession(record))) byKey.set(key, {
|
|
79
110
|
provider: record.provider, session: record.session, cwd: record.cwd, title: '', source: record.admission?.source || 'hook',
|
|
@@ -99,14 +130,17 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
99
130
|
? { ready: false, code: 'session_metadata_unavailable', reason: claudeError }
|
|
100
131
|
: await liveClaudeChannel(record, item, inspectProcess) : null;
|
|
101
132
|
const liveHook = claudeChannel?.ready === true;
|
|
102
|
-
const
|
|
133
|
+
const upgradeable = canUpgradeDelivery(run);
|
|
134
|
+
const continuationIssue = liveHook && run ? !run.managed && !upgradeable
|
|
103
135
|
? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
|
|
104
136
|
: TERMINAL_RUNS.has(run.state) ? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null : null;
|
|
105
137
|
let isRunning = item.isRunning;
|
|
106
|
-
|
|
138
|
+
const hookIsNewer = Number.isFinite(record?.runtime?.observedAt) && Number.isFinite(item.lastActivityAt) && record.runtime.observedAt > item.lastActivityAt;
|
|
139
|
+
if ((isRunning === undefined || isRunning === false && hookIsNewer) && record?.runtime?.process?.identity && record.cwd === item.cwd) {
|
|
107
140
|
try {
|
|
108
|
-
//
|
|
109
|
-
//
|
|
141
|
+
// A hook newer than the persisted provider activity may describe a
|
|
142
|
+
// new turn. Require its same live process; older hooks cannot override
|
|
143
|
+
// a more recent completed provider turn.
|
|
110
144
|
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) isRunning = true;
|
|
111
145
|
else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
|
|
112
146
|
} catch { /* Unverified process leaves runtime status unknown. */ }
|
|
@@ -114,6 +148,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
114
148
|
return { provider: item.provider, session: item.session, cwd: item.cwd,
|
|
115
149
|
title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
|
|
116
150
|
...(typeof isRunning === 'boolean' ? { isRunning } : {}),
|
|
151
|
+
...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
|
|
117
152
|
...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
|
|
118
153
|
...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
|
|
119
154
|
tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
|
|
@@ -129,7 +164,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
129
164
|
capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
|
|
130
165
|
canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
|
|
131
166
|
canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
|
|
132
|
-
canAutoContinue: (supported || liveHook) && (!run || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
|
|
167
|
+
canAutoContinue: (supported || liveHook) && (!run || upgradeable || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
|
|
133
168
|
reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
|
|
134
169
|
codexError || (!supported ? 'Exact model or provider metadata is unavailable.' : null),
|
|
135
170
|
};
|
|
@@ -150,9 +185,10 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
150
185
|
export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, ...options }) {
|
|
151
186
|
if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
|
|
152
187
|
if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
|
|
153
|
-
|
|
188
|
+
const updated = await updateRecord(root, provider, session, async previous => {
|
|
154
189
|
if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
|
|
155
|
-
if (enabled && previous.
|
|
190
|
+
if (enabled && previous.providerArchived === true) throw new Error('Restore the original provider conversation before enabling automatic continuation.');
|
|
191
|
+
if (enabled && previous.run && !previous.run.managed && !canUpgradeDelivery(previous.run)) throw new Error('Resolve the existing loop blocker before enabling automatic continuation.');
|
|
156
192
|
if (enabled && TERMINAL_RUNS.has(previous.run?.state)) throw new Error('This conversation is settled. Resume an eligible existing task after refreshing its status.');
|
|
157
193
|
if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
158
194
|
if (enabled && provider === 'claude') await requireClaudeChannel(previous,
|
|
@@ -171,6 +207,14 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
171
207
|
if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
|
|
172
208
|
return next;
|
|
173
209
|
});
|
|
210
|
+
if (enabled && updated.run && !updated.run.managed) {
|
|
211
|
+
// Enrollment checks this exact opt-in epoch under its state lock, so an
|
|
212
|
+
// overlapping Off action prevents the promotion from dispatching work.
|
|
213
|
+
await continueSession({ ...options, root, provider, session,
|
|
214
|
+
...updated.autoContinueSettings, autoContinueEpoch: updated.autoContinueEpoch });
|
|
215
|
+
return readRecord(root, provider, session);
|
|
216
|
+
}
|
|
217
|
+
return updated;
|
|
174
218
|
}
|
|
175
219
|
|
|
176
220
|
export async function readSessionAutoContinue({ root, provider, session }) {
|
|
@@ -194,6 +238,7 @@ export async function trackSession({ root, provider, session, tracked = true, op
|
|
|
194
238
|
if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
|
|
195
239
|
const cwd = await realpath(metadata.cwd);
|
|
196
240
|
await updateRecord(root, provider, session, previous => {
|
|
241
|
+
if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before enabling automatic continuation or tracking.');
|
|
197
242
|
if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
|
|
198
243
|
return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), tracked: true, monitor: true,
|
|
199
244
|
admission: { ...(previous?.admission || {}), source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
|
|
@@ -214,6 +259,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
214
259
|
if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
|
|
215
260
|
const cwd = await realpath(metadata.cwd);
|
|
216
261
|
await updateRecord(root, provider, session, previous => {
|
|
262
|
+
if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before enabling supervision.');
|
|
217
263
|
if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
|
|
218
264
|
return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), monitor,
|
|
219
265
|
admission: { source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
|
|
@@ -222,7 +268,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
222
268
|
}
|
|
223
269
|
|
|
224
270
|
export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
|
|
225
|
-
maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, openClient = openCodexAppServer,
|
|
271
|
+
maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, openClient = openCodexAppServer,
|
|
226
272
|
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
|
|
227
273
|
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
228
274
|
if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
|
|
@@ -232,7 +278,7 @@ export async function enrollSession({ root, provider, session, objective, accept
|
|
|
232
278
|
metadata = await readClaude(session);
|
|
233
279
|
const record = await readRecord(root, provider, session);
|
|
234
280
|
await requireClaudeChannel(record, metadata, inspectProcess);
|
|
235
|
-
if (record.run) throw new Error('This session already has a Delivery Loop.');
|
|
281
|
+
if (record.run && !upgradeRunId) throw new Error('This session already has a Delivery Loop.');
|
|
236
282
|
await monitorSession({ root, provider, session, monitor: true, readClaude: async () => metadata, now });
|
|
237
283
|
// A second check narrows the time between metadata admission and the
|
|
238
284
|
// original provider's next Stop boundary. Process exit still remains a
|
|
@@ -246,7 +292,7 @@ export async function enrollSession({ root, provider, session, objective, accept
|
|
|
246
292
|
const initialPlan = initialDiagnosisPlan(objective.trim(), contextBootstrap);
|
|
247
293
|
const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
|
|
248
294
|
const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
|
|
249
|
-
const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
295
|
+
const run = await startDelivery({ root, provider, session, cwd, executionCwd, upgradeRunId, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
250
296
|
maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
|
|
251
297
|
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
252
298
|
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
@@ -257,6 +303,14 @@ export async function enrollSession({ root, provider, session, objective, accept
|
|
|
257
303
|
}
|
|
258
304
|
|
|
259
305
|
export async function continueSession(options) {
|
|
306
|
+
const record = await readRecord(options.root, options.provider, options.session);
|
|
307
|
+
if (record?.run && !record.run.managed) {
|
|
308
|
+
// The user asked to continue this task. Adopt its goal and failure budget,
|
|
309
|
+
// rather than enrolling the generic context-bootstrap goal as a new run.
|
|
310
|
+
return enrollSession({ ...options, objective: record.run.objective, upgradeRunId: record.run.id,
|
|
311
|
+
maxFailures: record.run.maxFailures, contextBootstrap: false,
|
|
312
|
+
acceptanceCriteria: [{ id: 'original-goal', description: record.run.objective.slice(0, 500), check: { type: 'manual' } }] });
|
|
313
|
+
}
|
|
260
314
|
return enrollSession({ ...options, objective: 'Understand and continue the selected conversation’s current user request.',
|
|
261
315
|
acceptanceCriteria: [], contextBootstrap: true });
|
|
262
316
|
}
|
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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;
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
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)
|
|
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)
|
|
28
|
-
const
|
|
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)
|
|
31
|
-
|
|
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)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
41
|
-
|
|
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)
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
+
}
|
|
@@ -11,7 +11,7 @@ export function safeProviderDiagnostic(value) {
|
|
|
11
11
|
if (SIGNALS.has(value.signal)) result.signal = value.signal;
|
|
12
12
|
if (Number.isInteger(value.outputBytes) && value.outputBytes >= 0 && value.outputBytes <= 2147483647) result.outputBytes = value.outputBytes;
|
|
13
13
|
if (value.retryAttempt === 0 || value.retryAttempt === 1) result.retryAttempt = value.retryAttempt;
|
|
14
|
-
if (typeof value.model === "string" && /^(?:gpt-6-(?:astra)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
|
|
14
|
+
if (typeof value.model === "string" && /^(?:gpt-6-(?:astra|sol)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
|
|
15
15
|
return result;
|
|
16
16
|
}
|
|
17
17
|
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -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";
|
|
@@ -20,7 +21,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
|
|
|
20
21
|
const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
|
|
21
22
|
// Keep analysis independent of interactive config. Availability varies by
|
|
22
23
|
// account and CLI; only an explicit model rejection may try the next model.
|
|
23
|
-
|
|
24
|
+
// Background SQM and conversation triage use Sol models to limit usage.
|
|
25
|
+
export const SQM_CODEX_MODELS = Object.freeze(["gpt-6.1-sol", "gpt-6-sol"]);
|
|
24
26
|
export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
|
|
25
27
|
|
|
26
28
|
export class SqmLocalReviewError extends Error {
|
|
@@ -108,6 +110,7 @@ export function createSqmLocalReviewWorker({
|
|
|
108
110
|
now = () => Date.now(),
|
|
109
111
|
providerFailureCooldownMs = 5 * 60_000,
|
|
110
112
|
getRecoveryKey = () => null,
|
|
113
|
+
failureCircuit = createSqmFailureCircuit(),
|
|
111
114
|
} = {}) {
|
|
112
115
|
if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
|
|
113
116
|
let activeJobId = null;
|
|
@@ -186,7 +189,6 @@ export function createSqmLocalReviewWorker({
|
|
|
186
189
|
kind: "analysis", state: "running", provider, startedAt: new Date().toISOString(), webUrl: analysisTaskUrl(workspaceUrl, claim.job_id) });
|
|
187
190
|
historyScopes.set(history[0], JSON.stringify(historyScope));
|
|
188
191
|
history.splice(50);
|
|
189
|
-
await persist(historyScope, history[0]);
|
|
190
192
|
onChange();
|
|
191
193
|
let heartbeatTimer = null;
|
|
192
194
|
const heartbeat = () => {
|
|
@@ -202,6 +204,8 @@ export function createSqmLocalReviewWorker({
|
|
|
202
204
|
const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
|
|
203
205
|
? { reported_executor_provider: review.provider || selectedProvider } : {};
|
|
204
206
|
try {
|
|
207
|
+
await failureCircuit.begin(historyScope);
|
|
208
|
+
await persist(historyScope, history[0]);
|
|
205
209
|
const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => request({
|
|
206
210
|
action: "advance",
|
|
207
211
|
...leaseBody(claim, provider),
|
|
@@ -249,7 +253,11 @@ export function createSqmLocalReviewWorker({
|
|
|
249
253
|
return {
|
|
250
254
|
snapshot() {
|
|
251
255
|
const pause = currentPause();
|
|
252
|
-
|
|
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,
|
|
253
261
|
...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
|
|
254
262
|
},
|
|
255
263
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
|
|
@@ -257,7 +265,7 @@ export function createSqmLocalReviewWorker({
|
|
|
257
265
|
const item = visibleHistory().find(item => item.key === key);
|
|
258
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;
|
|
259
267
|
},
|
|
260
|
-
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; },
|
|
261
269
|
resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
|
|
262
270
|
poll() {
|
|
263
271
|
if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
|
|
@@ -278,6 +286,8 @@ export function createSqmLocalReviewWorker({
|
|
|
278
286
|
const workspaceUrl = getWorkspaceUrl();
|
|
279
287
|
reviewController = new AbortController();
|
|
280
288
|
pollPromise = (async () => {
|
|
289
|
+
if (!await failureCircuit.canClaim(historyScope)) return null;
|
|
290
|
+
if (reviewController.signal.aborted) throw providerFailure("analysis_cancelled");
|
|
281
291
|
const claim = await api.request({
|
|
282
292
|
action: "claim",
|
|
283
293
|
executor_provider: provider,
|
|
@@ -292,11 +302,16 @@ export function createSqmLocalReviewWorker({
|
|
|
292
302
|
return null;
|
|
293
303
|
}
|
|
294
304
|
const result = await processClaim(claim, provider, historyScope, workspaceUrl);
|
|
305
|
+
try { await failureCircuit.success(historyScope); } catch (storageError) { onError(storageError); }
|
|
295
306
|
pauses.delete(key);
|
|
296
307
|
return result;
|
|
297
308
|
})()
|
|
298
309
|
.catch(async (error) => {
|
|
299
|
-
if (error.code === "analysis_cancelled")
|
|
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); }
|
|
300
315
|
pauseForFailure(error, provider, historyScope, recoveryKey);
|
|
301
316
|
if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
|
|
302
317
|
const scope = historyScope;
|