@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.
- 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/codex-app-server.js +72 -8
- package/src/session-supervision/continuation-analysis.js +46 -9
- package/src/session-supervision/index.js +70 -17
- package/src/session-supervision/statistics.js +138 -60
- package/src/sqm/failure-circuit.js +105 -0
- package/src/sqm/review-worker.js +18 -4
|
@@ -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
|
+
}
|
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";
|
|
@@ -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
|
-
|
|
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")
|
|
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;
|