@awak-app/simy-cli 0.6.16 → 0.6.18
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 +99 -6
- package/package.json +1 -1
- package/src/agent.js +123 -28
- package/src/auto-update.js +15 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
- package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
- package/src/codex-plugins/simy/hooks/hooks.json +2 -2
- package/src/codex-work-pattern-sync.js +37 -9
- package/src/delivery-hooks/command.js +9 -2
- package/src/delivery-hooks/desktop.js +10 -12
- package/src/delivery-hooks/evidence.js +50 -0
- package/src/delivery-hooks/supervisor.js +8 -2
- package/src/desktop-access.js +4 -3
- package/src/desktop-host.js +669 -73
- package/src/doctor.js +2 -1
- package/src/host-exit-diagnostics.js +28 -0
- package/src/index.js +1 -0
- package/src/native-development-report-completion.js +143 -0
- package/src/native-development-reports.js +137 -0
- package/src/orchestrator/checklist-continuation.js +5 -0
- package/src/orchestrator/delivery/WORKFLOW.md +4 -0
- package/src/orchestrator/delivery/references/state-model.md +187 -0
- package/src/orchestrator/loop.js +119 -34
- package/src/provider-links.js +74 -0
- package/src/runner.js +31 -2
- package/src/session-supervision/automatic-continuation.js +31 -2
- package/src/session-supervision/claude-sessions.js +25 -15
- package/src/session-supervision/codex-app-server.js +27 -4
- package/src/session-supervision/index.js +163 -42
- package/src/session-supervision/statistics.js +76 -15
- package/src/sqm/command.js +58 -7
- package/src/sqm/evidence-client.js +11 -1
- package/src/sqm/failure-circuit.js +28 -6
- package/src/sqm/history-store.js +49 -18
- package/src/sqm/provider-diagnostics.js +1 -0
- package/src/sqm/review-worker.js +70 -21
- package/src/sqm/workflow-update.js +12 -3
- package/src/windows-bootstrap.js +2 -1
- package/src/windows-desktop-updater.js +6 -1
- package/src/windows-inbound.js +9 -0
package/src/sqm/command.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sqmConversationIdentity } from './conversation-identity.js';
|
|
2
2
|
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
3
|
-
import { createSqmCheckRecorder } from "./history-store.js";
|
|
3
|
+
import { createSqmHistoryStore, createSqmCheckRecorder } from "./history-store.js";
|
|
4
4
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
@@ -8,6 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
import { isSessionValid, readSession } from "../session-store.js";
|
|
9
9
|
import { resolveWebOrigin } from "../web-origin.js";
|
|
10
10
|
import { submitLocalEvidence } from "./local-evidence.js";
|
|
11
|
+
import { verifySignedEvidence } from "./proof.js";
|
|
11
12
|
import { uploadRunEvidence } from "./evidence-client.js";
|
|
12
13
|
import { evaluateKnowledgeBundle } from "./checkers.js";
|
|
13
14
|
import { runSqmCheck, runSqmMinCheck } from "./index.js";
|
|
@@ -24,7 +25,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
24
25
|
if (argv[0] === "submit-evidence") return await submitLocalEvidence(argv);
|
|
25
26
|
if (argv[0] === "lint") return await runLintCommand(argv);
|
|
26
27
|
if (argv[0] === "replay") return await runReplayCommand(argv);
|
|
27
|
-
if (!["min-check", "check"].includes(argv[0])) throw new Error("Usage: simy sqm <min-check|check|lint|replay> [options]");
|
|
28
|
+
if (!["min-check", "check", "upload-proof"].includes(argv[0])) throw new Error("Usage: simy sqm <min-check|check|upload-proof|lint|replay> [options]");
|
|
28
29
|
const webOrigin = resolveWebOrigin(option(argv, "--host"));
|
|
29
30
|
const authorizationLogger = argv.includes("--json")
|
|
30
31
|
? { log: message => console.warn(message), error: message => console.error(message) }
|
|
@@ -40,6 +41,28 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
40
41
|
const accountId = process.env.SIMY_SQM_ACCOUNT_ID || validSession?.account_id || validSession?.auth_user_id || validSession?.device_id;
|
|
41
42
|
const deviceId = process.env.SIMY_SQM_DEVICE_ID || validSession?.device_id;
|
|
42
43
|
if (!organizationId) throw new Error(`SQM needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
|
|
44
|
+
if (argv[0] === "upload-proof") {
|
|
45
|
+
if (argv.includes("--offline") || argv.includes("--no-upload")) throw new Error("upload-proof requires online evidence storage.");
|
|
46
|
+
const proofPath = path.resolve(requiredOption(argv, "--proof"));
|
|
47
|
+
const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
|
|
48
|
+
const [proof, signedEvidence] = await Promise.all([readJson(proofPath), readJson(evidencePath)]);
|
|
49
|
+
verifySignedEvidence(proof, signedEvidence);
|
|
50
|
+
if (!accountId || !deviceId || proof.organization_id !== organizationId ||
|
|
51
|
+
signedEvidence.signer.account_id !== accountId || signedEvidence.signer.device_id !== deviceId) {
|
|
52
|
+
throw new Error("Saved SQM evidence does not belong to the current organization, account and device.");
|
|
53
|
+
}
|
|
54
|
+
const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
|
|
55
|
+
const upload = await uploadRetainedProof({ endpoint: evidenceEndpoint, token, proof, signedEvidence });
|
|
56
|
+
await saveUploadReceipt(`${proofPath}.upload.json`, upload);
|
|
57
|
+
if (upload.uploaded) {
|
|
58
|
+
try {
|
|
59
|
+
await createSqmHistoryStore().reconcileUpload({ webOrigin, accountId, organizationId }, { proof, signedEvidence, upload });
|
|
60
|
+
} catch { upload.history_warning = "local_history_update_failed"; }
|
|
61
|
+
}
|
|
62
|
+
if (argv.includes("--json")) console.log(JSON.stringify(upload, null, 2));
|
|
63
|
+
else console.log(upload.uploaded ? `Cloud evidence: stored · ${upload.evidence_id}` : `Cloud evidence: local only (${upload.reason}). Original proof retained.`);
|
|
64
|
+
return upload.uploaded ? 0 : 2;
|
|
65
|
+
}
|
|
43
66
|
const endpoint = process.env.SIMY_SQM_BUNDLE_URL || (validSession?.api_base_url ? new URL("sqm/knowledge-bundle", validSession.api_base_url).href : undefined);
|
|
44
67
|
const { providerSession, conversationProvider } = await sqmConversationIdentity({ session: option(argv, "--session"), provider: option(argv, "--provider") });
|
|
45
68
|
let checkedDescriptions = [];
|
|
@@ -130,19 +153,21 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
130
153
|
if (!argv.includes("--no-upload")) {
|
|
131
154
|
const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
|
|
132
155
|
stage = "evidence_upload";
|
|
133
|
-
upload = await
|
|
156
|
+
upload = await uploadRetainedProof({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
|
|
134
157
|
}
|
|
158
|
+
await saveUploadReceipt(`${proofPath}.upload.json`, upload);
|
|
135
159
|
result.upload = upload;
|
|
136
160
|
result.artifacts = {
|
|
137
161
|
min_check: minPath,
|
|
138
162
|
proof: proofPath,
|
|
139
163
|
signed_evidence: evidencePath,
|
|
140
164
|
diagnostics: diagnosticsPath,
|
|
165
|
+
upload: `${proofPath}.upload.json`,
|
|
141
166
|
};
|
|
142
167
|
await save(result);
|
|
143
168
|
if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
144
|
-
else printFull(result);
|
|
145
|
-
return result.proof.outcome.status === "failed" ? 1 : 0;
|
|
169
|
+
else printFull(result, webOrigin);
|
|
170
|
+
return result.proof.outcome.status === "failed" ? 1 : !argv.includes("--no-upload") && !upload.uploaded ? 2 : 0;
|
|
146
171
|
} catch (error) {
|
|
147
172
|
await save({ status: "failed", diagnostic: { ...safeSqmDiagnostic(error, stage), ...sqmDiagnostic(error, stage) } });
|
|
148
173
|
throw error;
|
|
@@ -153,6 +178,7 @@ function printHelp() {
|
|
|
153
178
|
console.log(`Usage:
|
|
154
179
|
simy sqm min-check [options]
|
|
155
180
|
simy sqm check [options]
|
|
181
|
+
simy sqm upload-proof --proof <path> [--signed-evidence <path>] [--host <origin>] [--json]
|
|
156
182
|
simy sqm submit-evidence --file <delivery.json> [--host <origin>] [--dry-run]
|
|
157
183
|
simy sqm lint --bundle <path>
|
|
158
184
|
simy sqm replay --bundle <path> --dataset <path>
|
|
@@ -162,6 +188,8 @@ Two-stage SQM flow:
|
|
|
162
188
|
check Full state/invariant/stress/strength/scenario verification. It
|
|
163
189
|
verifies the pinned min-check identity, writes proof.json, and
|
|
164
190
|
produces independent per-run signed evidence.
|
|
191
|
+
upload-proof Explicitly resend the original signed proof without rerunning
|
|
192
|
+
checks. Saves the storage acknowledgement beside the proof.
|
|
165
193
|
lint Report broad matchers and missing evaluation gates before publish.
|
|
166
194
|
replay Evaluate a bundle against labelled historical repository snapshots.
|
|
167
195
|
|
|
@@ -271,14 +299,17 @@ function printUnavailable(result, argv) {
|
|
|
271
299
|
return 2;
|
|
272
300
|
}
|
|
273
301
|
|
|
274
|
-
function printFull(result) {
|
|
302
|
+
function printFull(result, webOrigin) {
|
|
275
303
|
console.log(`SQM full check: ${result.proof.outcome.status} · ${result.findings.length} finding(s)`);
|
|
276
304
|
console.log(formatSqmCoverage(result.proof));
|
|
277
305
|
console.log(`Proof ${result.proof.proof_id} (${result.proof.integrity.digest})`);
|
|
278
306
|
console.log(`Per-run signature: valid · ${result.signature_verification.key_id}`);
|
|
279
307
|
console.log(`Artifacts: ${result.artifacts.proof} · ${result.artifacts.signed_evidence} · ${result.artifacts.diagnostics}`);
|
|
280
308
|
if (result.upload.uploaded) console.log(`Cloud evidence: stored · ${result.upload.evidence_id || result.signed_evidence.evidence_id}`);
|
|
281
|
-
else
|
|
309
|
+
else {
|
|
310
|
+
console.log(`Cloud evidence: local only (${result.upload.reason})`);
|
|
311
|
+
if (result.upload.reason !== "disabled") console.log(`Storage not confirmed. Recover with: simy sqm upload-proof --proof ${shellArgument(result.artifacts.proof)} --signed-evidence ${shellArgument(result.artifacts.signed_evidence)} --host ${shellArgument(webOrigin)}`);
|
|
312
|
+
}
|
|
282
313
|
}
|
|
283
314
|
|
|
284
315
|
function defaultMinResultPath() {
|
|
@@ -300,3 +331,23 @@ function option(argv, name) {
|
|
|
300
331
|
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value.`);
|
|
301
332
|
return value;
|
|
302
333
|
}
|
|
334
|
+
|
|
335
|
+
async function uploadRetainedProof(options) {
|
|
336
|
+
try { return await uploadRunEvidence(options); }
|
|
337
|
+
catch (error) {
|
|
338
|
+
const diagnostic = safeSqmDiagnostic(error, "evidence_upload", "evidence_upload_failed");
|
|
339
|
+
return { uploaded: false, reason: diagnostic.code, diagnostic };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function shellArgument(value) {
|
|
344
|
+
return "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function saveUploadReceipt(target, upload) {
|
|
348
|
+
try { await atomicJson(target, upload); }
|
|
349
|
+
catch {
|
|
350
|
+
upload.receipt_warning = "local_receipt_write_failed";
|
|
351
|
+
console.warn(`Could not save the local storage receipt. Cloud storage is ${upload.uploaded ? "confirmed" : "unconfirmed"}.`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -26,5 +26,15 @@ export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence
|
|
|
26
26
|
requestId: response.headers?.get?.("x-request-id") }, "evidence_upload", "evidence_upload_failed"));
|
|
27
27
|
throw error;
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
const evidence = payload?.evidence;
|
|
30
|
+
if (payload?.signature_valid !== true || typeof evidence?.id !== "string" || !evidence.id ||
|
|
31
|
+
evidence.proof_id !== proof.proof_id || evidence.evidence_id !== signedEvidence.evidence_id ||
|
|
32
|
+
evidence.proof_digest !== proof.integrity.digest) {
|
|
33
|
+
const error = new Error("SQM storage response did not acknowledge the submitted evidence identity.");
|
|
34
|
+
error.code = "evidence_acknowledgement_invalid";
|
|
35
|
+
error.stage = "evidence_upload";
|
|
36
|
+
error.retryable = false;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return { uploaded: true, evidence_id: evidence.evidence_id, record_id: evidence.id };
|
|
30
40
|
}
|
|
@@ -19,7 +19,7 @@ export function createSqmFailureCircuitStore({ root = defaultSessionRoot() } = {
|
|
|
19
19
|
async read(scope) {
|
|
20
20
|
try {
|
|
21
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"))
|
|
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") || (state.providerFailures !== undefined && (!Number.isInteger(state.providerFailures) || state.providerFailures < 0 || state.providerFailures > 2)) || (state.providerRetryAt !== undefined && (!Number.isFinite(state.providerRetryAt) || state.providerRetryAt < 0)))
|
|
23
23
|
throw new Error("Invalid saved SQM failure state.");
|
|
24
24
|
return state;
|
|
25
25
|
} catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
@@ -42,7 +42,7 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
42
42
|
const states = new Map();
|
|
43
43
|
const unreadableScopes = new Set();
|
|
44
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; }
|
|
45
|
+
try { await store?.write(scope, { version: state.version, failures: state.failures, inFlight: state.inFlight === true, ...(state.providerFailures ? { providerFailures: state.providerFailures, providerRetryAt: state.providerRetryAt } : {}) }); state.storageError = false; }
|
|
46
46
|
catch (error) { state.storageError = true; throw error; }
|
|
47
47
|
};
|
|
48
48
|
const load = async scope => {
|
|
@@ -52,18 +52,20 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
52
52
|
let saved;
|
|
53
53
|
try { saved = await store?.read(scope); unreadableScopes.delete(key); }
|
|
54
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 };
|
|
55
|
+
state = saved ? { version: saved.version, failures: saved.failures, inFlight: saved.inFlight === true, providerFailures: saved.providerFailures || 0, providerRetryAt: saved.providerRetryAt || 0 } : { version, failures: 0, inFlight: false };
|
|
56
56
|
states.set(key, state);
|
|
57
57
|
// An unfinished review from this same version means the last host exited
|
|
58
58
|
// without a result or graceful cancellation. Count it once on restart.
|
|
59
59
|
if (state.inFlight && compareVersions(version, state.version) <= 0) {
|
|
60
60
|
state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
|
|
61
|
+
if (state.providerFailures === 1) state.providerFailures = 2;
|
|
61
62
|
state.inFlight = false;
|
|
62
63
|
await save(scope, state);
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
if (compareVersions(version, state.version) > 0) {
|
|
66
67
|
state.version = version;
|
|
68
|
+
state.providerFailures = 0; state.providerRetryAt = 0;
|
|
67
69
|
state.failures = 0;
|
|
68
70
|
state.inFlight = false;
|
|
69
71
|
await save(scope, state);
|
|
@@ -71,7 +73,22 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
71
73
|
return state;
|
|
72
74
|
};
|
|
73
75
|
return {
|
|
74
|
-
async canClaim(scope
|
|
76
|
+
async canClaim(scope, now = Date.now()) {
|
|
77
|
+
const state = await load(scope);
|
|
78
|
+
return state.failures < SQM_FAILURE_PAUSE_THRESHOLD && (state.providerFailures || 0) < 2 && now >= (state.providerRetryAt || 0);
|
|
79
|
+
},
|
|
80
|
+
async providerFailure(scope, retryAt, retryAttempt = 0) {
|
|
81
|
+
const state = await load(scope);
|
|
82
|
+
state.providerFailures = retryAttempt === 1 ? 2 : Math.min(2, (state.providerFailures || 0) + 1);
|
|
83
|
+
state.providerRetryAt = retryAt;
|
|
84
|
+
await save(scope, state);
|
|
85
|
+
},
|
|
86
|
+
async providerRetryUsed(scope) {
|
|
87
|
+
const state = await load(scope);
|
|
88
|
+
if (state.providerFailures) return;
|
|
89
|
+
state.providerFailures = 1; state.providerRetryAt = 0;
|
|
90
|
+
await save(scope, state);
|
|
91
|
+
},
|
|
75
92
|
async begin(scope) {
|
|
76
93
|
const state = await load(scope);
|
|
77
94
|
state.inFlight = true;
|
|
@@ -85,20 +102,25 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
85
102
|
async failure(scope) {
|
|
86
103
|
const state = await load(scope);
|
|
87
104
|
state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
|
|
105
|
+
if (state.inFlight && state.providerFailures === 1) state.providerFailures = 2;
|
|
88
106
|
state.inFlight = false;
|
|
89
107
|
await save(scope, state);
|
|
90
108
|
},
|
|
91
109
|
async success(scope) {
|
|
92
110
|
const state = await load(scope);
|
|
93
111
|
state.failures = 0;
|
|
112
|
+
state.providerFailures = 0; state.providerRetryAt = 0;
|
|
94
113
|
state.inFlight = false;
|
|
95
114
|
await save(scope, state);
|
|
96
115
|
},
|
|
97
116
|
snapshot(scope) {
|
|
98
117
|
const state = states.get(scopeKey(scope));
|
|
118
|
+
const paused = (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD || (state?.providerFailures || 0) >= 2;
|
|
99
119
|
return { consecutive_failures: state?.failures || 0,
|
|
100
|
-
paused_until_update:
|
|
101
|
-
|
|
120
|
+
paused_until_update: paused,
|
|
121
|
+
provider_retry_exhausted: (state?.providerFailures || 0) >= 2,
|
|
122
|
+
provider_recovery_pending: state?.providerFailures === 1,
|
|
123
|
+
paused_at_version: paused ? state.version : null,
|
|
102
124
|
storage_unavailable: state?.storageError === true || unreadableScopes.has(scopeKey(scope)) };
|
|
103
125
|
},
|
|
104
126
|
};
|
package/src/sqm/history-store.js
CHANGED
|
@@ -7,7 +7,8 @@ import { basename, join } from "node:path";
|
|
|
7
7
|
import { formatSqmCoverage, sqmCoverageWarnings } from "./report.js";
|
|
8
8
|
import { defaultSessionRoot } from "../session-store.js";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Limit the desktop projection, never the durable history.
|
|
11
|
+
const DISPLAY_LIMIT = 100;
|
|
11
12
|
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
12
13
|
|
|
13
14
|
export function sqmHistoryScope(webOrigin, session) {
|
|
@@ -28,13 +29,14 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
|
|
|
28
29
|
if (cached && now() - cached.at < 3000) return cached.items;
|
|
29
30
|
let names;
|
|
30
31
|
try { names = await readdir(dir); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
31
|
-
const items =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
32
|
+
const items = [];
|
|
33
|
+
const files = names.filter(name => /^[a-f0-9]{64}\.json$/.test(name));
|
|
34
|
+
// Retained history can outgrow the process file-descriptor limit.
|
|
35
|
+
for (let offset = 0; offset < files.length; offset += 16) {
|
|
36
|
+
items.push(...(await Promise.all(files.slice(offset, offset + 16)
|
|
37
|
+
.map(name => readItem(dir, name)))).filter(Boolean));
|
|
38
|
+
}
|
|
39
|
+
items.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
38
40
|
cache.set(dir, { at: now(), items });
|
|
39
41
|
return items;
|
|
40
42
|
}
|
|
@@ -52,31 +54,59 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
|
|
|
52
54
|
task_id: item.id, provider_diagnostic: item.providerDiagnostic,
|
|
53
55
|
http_status: item.errorStatus ?? item.httpStatus, at: item.finishedAt || item.startedAt });
|
|
54
56
|
} finally { await rm(temp, { force: true }); cache.delete(dir); }
|
|
55
|
-
const items = await list(scope);
|
|
56
|
-
for (const kind of ["analysis", "check"]) {
|
|
57
|
-
await Promise.all(items.filter(row => row.kind === kind).slice(LIMIT).map(row => rm(join(dir, `${hash(row.key)}.json`), { force: true })));
|
|
58
|
-
}
|
|
59
|
-
cache.delete(dir);
|
|
60
57
|
return true;
|
|
61
58
|
},
|
|
59
|
+
async reconcileUpload(scope, { proof, signedEvidence, upload }) {
|
|
60
|
+
if (!upload?.uploaded) return 0;
|
|
61
|
+
let updated = 0;
|
|
62
|
+
for (const row of await list(scope)) {
|
|
63
|
+
if (row.kind !== "check" || row.proofId !== proof.proof_id ||
|
|
64
|
+
row.proofDigest !== proof.integrity.digest || row.evidenceId !== signedEvidence.evidence_id) continue;
|
|
65
|
+
const previousFailure = row.errorStage === "evidence_upload" ? row.error : null;
|
|
66
|
+
const next = { ...row, result: (row.result || "").replace(/Evidence upload: [^\n]*/u,
|
|
67
|
+
`Evidence upload: uploaded · ${upload.evidence_id} · record ${upload.record_id}`) };
|
|
68
|
+
if (previousFailure) {
|
|
69
|
+
next.result += `\nPrevious storage failure (recovered): ${previousFailure}`;
|
|
70
|
+
for (const key of ["error", "errorCode", "errorStage", "errorStatus", "requestId"]) delete next[key];
|
|
71
|
+
}
|
|
72
|
+
await this.record(scope, next);
|
|
73
|
+
updated++;
|
|
74
|
+
}
|
|
75
|
+
return updated;
|
|
76
|
+
},
|
|
62
77
|
async items(scope) {
|
|
63
78
|
// A saved running row is not evidence that this process owns its lease.
|
|
64
79
|
return (await list(scope)).map(item => ({ ...item, state: item.state === "running" ? "interrupted" : item.state }));
|
|
65
80
|
},
|
|
66
81
|
async detail(scope, key) {
|
|
67
|
-
const
|
|
68
|
-
if (!
|
|
82
|
+
const dir = directory(scope);
|
|
83
|
+
if (!dir || typeof key !== "string") return null;
|
|
84
|
+
const saved = await readItem(dir, `${hash(key)}.json`);
|
|
85
|
+
if (!saved || saved.key !== key) return null;
|
|
86
|
+
const item = { ...saved, state: saved.state === "running" ? "interrupted" : saved.state };
|
|
69
87
|
return { key, instruction: item.instruction || `SQM local review · ${item.provider || "local"}`,
|
|
70
88
|
result: item.result || "", error: item.state === "interrupted" ? "This client stopped before the local run finished. Check the web workspace for its current retry status." : item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] };
|
|
71
89
|
},
|
|
72
90
|
};
|
|
73
91
|
}
|
|
74
92
|
|
|
93
|
+
async function readItem(dir, name) {
|
|
94
|
+
try {
|
|
95
|
+
const text = await readFile(join(dir, name), "utf8");
|
|
96
|
+
if (text.length > 16_384) return null;
|
|
97
|
+
return metadata(JSON.parse(text));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// One damaged or unreadable archived entry must not hide healthy history.
|
|
100
|
+
if (["ENOENT", "EISDIR", "EACCES", "EPERM"].includes(error.code) || error instanceof SyntaxError) return null;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
function metadata(entry) {
|
|
76
106
|
if (!entry || !["analysis", "check"].includes(entry.kind) || typeof entry.key !== "string" || !entry.key.startsWith(entry.kind === "analysis" ? "sqm:" : "sqm-check:")) return null;
|
|
77
107
|
if (!["running", "completed", "failed", "unavailable", "interrupted", "verification_pending"].includes(entry.state) || !Number.isFinite(Date.parse(entry.startedAt))) return null;
|
|
78
108
|
const item = {};
|
|
79
|
-
for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl", "providerSession", "conversationProvider", "outcome"]) {
|
|
109
|
+
for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl", "providerSession", "conversationProvider", "outcome", "proofId", "evidenceId", "proofDigest"]) {
|
|
80
110
|
if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
|
|
81
111
|
}
|
|
82
112
|
if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
|
|
@@ -92,7 +122,7 @@ function metadata(entry) {
|
|
|
92
122
|
export function projectSqmHistory(items, live = { activeCount: 0, items: [] }) {
|
|
93
123
|
const analyses = new Map(items.filter(item => item.kind === "analysis").map(item => [item.key, item]));
|
|
94
124
|
for (const item of live.items || []) analyses.set(item.key, { ...analyses.get(item.key), ...item });
|
|
95
|
-
const rows = items => items.sort((a, b) => (b.startedAt || "").localeCompare(a.startedAt || "")).slice(0,
|
|
125
|
+
const rows = items => items.sort((a, b) => (b.startedAt || "").localeCompare(a.startedAt || "")).slice(0, DISPLAY_LIMIT)
|
|
96
126
|
.map(({ instruction, result, error, ...item }) => item);
|
|
97
127
|
return { sqmTasks: { activeCount: live.activeCount || 0, items: rows([...analyses.values()]) },
|
|
98
128
|
sqmChecks: { activeCount: 0, items: rows(items.filter(item => item.kind === "check")) } };
|
|
@@ -117,12 +147,13 @@ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, pr
|
|
|
117
147
|
minCheck.matched_modules.reduce((total, module) => total + (module.scenario_ids?.length || 0), 0),
|
|
118
148
|
noSystemKnowledge: minCheck.matched_modules.length === 0 && minCheck.coverage_reasons?.includes("no_system_knowledge") === true,
|
|
119
149
|
outcomeStatus: minCheck.status }) : [];
|
|
120
|
-
const summary = proof ? [`Full check: ${proof.outcome.status}`, formatSqmCoverage(proof), `Evidence upload: ${result.upload?.uploaded ?
|
|
150
|
+
const summary = proof ? [`Full check: ${proof.outcome.status}`, formatSqmCoverage(proof), `Proof: ${proof.proof_id}`, `Evidence upload: ${result.upload?.uploaded ? `uploaded · ${result.upload.evidence_id} · record ${result.upload.record_id}` : "kept locally"}`].join("\n")
|
|
121
151
|
: state === "unavailable" ? "SQM could not obtain a verified result. See the recorded diagnostics below."
|
|
122
152
|
: state === "failed" && !identity ? "The command did not finish. See the recorded diagnostics below."
|
|
123
153
|
: `Min-check: ${result.min_check?.status || result.status} · ${(result.preliminary_findings || result.min_check?.preliminary_findings)?.length || 0} preliminary finding(s) · ${(result.matched_modules || result.min_check?.matched_modules)?.length || 0} matched module(s).\nThis is a preflight; run the full check for signed verification.`;
|
|
124
154
|
try {
|
|
125
155
|
await history.record(scope, { key, id: key, kind: "check", phase, state, startedAt, providerSession, conversationProvider, outcome,
|
|
156
|
+
proofId: proof?.proof_id, proofDigest: proof?.integrity?.digest, evidenceId: result.signed_evidence?.evidence_id,
|
|
126
157
|
checked: result.checked_descriptions || presentation.checked || [],
|
|
127
158
|
noApplicableChecks: (proof?.outcome.reasons || minCheck.coverage_reasons || []).includes('no_applicable_rules'),
|
|
128
159
|
findings: (proof?.findings || result.preliminary_findings || minCheck.preliminary_findings || []).length, finishedAt: new Date().toISOString(), repository,
|
|
@@ -10,6 +10,7 @@ export function safeProviderDiagnostic(value) {
|
|
|
10
10
|
if (Number.isInteger(value.exitCode) && value.exitCode >= 0 && value.exitCode <= 255) result.exitCode = value.exitCode;
|
|
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
|
+
if (["before_turn", "turn_started", "turn_output"].includes(value.phase)) result.phase = value.phase;
|
|
13
14
|
if (value.retryAttempt === 0 || value.retryAttempt === 1) result.retryAttempt = value.retryAttempt;
|
|
14
15
|
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
16
|
return result;
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -15,10 +15,17 @@ import {
|
|
|
15
15
|
webApiUrl,
|
|
16
16
|
} from "../web-api.js";
|
|
17
17
|
|
|
18
|
+
const RECOVERABLE_PROVIDER_FAILURES = new Set([
|
|
19
|
+
"provider_model_unsupported", "provider_process_failed", "provider_network_unavailable",
|
|
20
|
+
"provider_rate_limited", "provider_execution_error", "codex_local_review_failed", "claude_local_review_failed",
|
|
21
|
+
]);
|
|
22
|
+
const isRecoverableProviderFailure = failure => ["analysis", "adjudication"].includes(failure.stage) && RECOVERABLE_PROVIDER_FAILURES.has(failure.code);
|
|
18
23
|
const DEFAULT_LEASE_SECONDS = 300;
|
|
19
24
|
// Claim can retrieve and freeze organization-scoped Slack evidence (45s budget).
|
|
20
25
|
const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
|
|
21
|
-
const DEFAULT_EXECUTION_TIMEOUT_MS =
|
|
26
|
+
const DEFAULT_EXECUTION_TIMEOUT_MS = 360_000;
|
|
27
|
+
const CODEX_ATTEMPT_TIMEOUT_MS = 180_000;
|
|
28
|
+
const MIN_STALL_RETRY_MS = 60_000;
|
|
22
29
|
// Keep analysis independent of interactive config. Availability varies by
|
|
23
30
|
// account and CLI; only an explicit model rejection may try the next model.
|
|
24
31
|
// Background SQM and conversation triage use Sol models to limit usage.
|
|
@@ -124,13 +131,16 @@ export function createSqmLocalReviewWorker({
|
|
|
124
131
|
const pauses = new Map();
|
|
125
132
|
const contextKey = (provider, scope) => JSON.stringify([scope?.webOrigin, scope?.accountId, scope?.organizationId, provider]);
|
|
126
133
|
const currentPause = () => pauses.get(contextKey(getProvider(), getHistoryScope()));
|
|
127
|
-
const pauseForFailure = (error, provider, scope, recoveryKey) => {
|
|
134
|
+
const pauseForFailure = async (error, provider, scope, recoveryKey) => {
|
|
128
135
|
const failure = failureDetails.get(error) || reviewFailure(error, "claim", provider);
|
|
129
|
-
|
|
136
|
+
const recoverable = isRecoverableProviderFailure(failure) || failure.providerDiagnostic?.retryAttempt === 1
|
|
137
|
+
|| (["analysis", "adjudication"].includes(failure.stage) && failureCircuit.snapshot(scope).provider_recovery_pending);
|
|
138
|
+
if (!recoverable && !["provider_sign_in_required", "provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable", "desktop_access_required"].includes(failure.code) && ![401, 403].includes(failure.status)) return;
|
|
130
139
|
const key = contextKey(provider, scope);
|
|
131
140
|
const failures = Math.min((pauses.get(key)?.failures || 0) + 1, 4);
|
|
132
141
|
const delay = Math.min(30 * 60_000, Math.max(1000, providerFailureCooldownMs) * 2 ** (failures - 1));
|
|
133
|
-
|
|
142
|
+
if (recoverable) await failureCircuit.providerFailure(scope, now() + delay, failure.providerDiagnostic?.retryAttempt);
|
|
143
|
+
pauses.set(key, { recoverable, code: failure.code, stage: failure.stage, status: failure.status, failures, recoveryKey,
|
|
134
144
|
retryAt: now() + delay, message: `${formatFailure(failure)}\nNew SQM claims are paused. After fixing this cause, refresh the local AI connection or wait for the next recovery attempt.` });
|
|
135
145
|
if (pauses.size > 32) pauses.delete(pauses.keys().next().value);
|
|
136
146
|
};
|
|
@@ -167,7 +177,10 @@ export function createSqmLocalReviewWorker({
|
|
|
167
177
|
for (let attempt = 0; ; attempt += 1) {
|
|
168
178
|
setStage(false);
|
|
169
179
|
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
170
|
-
const review = await runReview(nextRequest, provider,
|
|
180
|
+
const review = await runReview(nextRequest, provider, {
|
|
181
|
+
...reviewOptions, allowAutomaticRetry: !failureCircuit.snapshot(reviewOptions.historyScope).provider_recovery_pending,
|
|
182
|
+
});
|
|
183
|
+
if (review.retryAttempt === 1) await failureCircuit.providerRetryUsed(reviewOptions.historyScope);
|
|
171
184
|
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
172
185
|
setStage(true);
|
|
173
186
|
try {
|
|
@@ -175,7 +188,11 @@ export function createSqmLocalReviewWorker({
|
|
|
175
188
|
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
176
189
|
return { review, response };
|
|
177
190
|
} catch (error) {
|
|
178
|
-
if (attempt >= 1 ||
|
|
191
|
+
if (attempt >= 1 || failureCircuit.snapshot(reviewOptions.historyScope).provider_recovery_pending ||
|
|
192
|
+
error.status !== 422 || error.code !== code || !error.validationFeedback) throw error;
|
|
193
|
+
// Native recovery, cooldown recovery and model-output correction share
|
|
194
|
+
// one durable retry budget across analysis and adjudication.
|
|
195
|
+
await failureCircuit.providerRetryUsed(reviewOptions.historyScope);
|
|
179
196
|
nextRequest = { ...request, prompt: `${request.prompt}\n\nThe previous result failed validation. Return a corrected complete JSON object using only the supplied evidence. Fix these constraints:\n${error.validationFeedback}` };
|
|
180
197
|
}
|
|
181
198
|
}
|
|
@@ -199,7 +216,7 @@ export function createSqmLocalReviewWorker({
|
|
|
199
216
|
let stage = "analysis";
|
|
200
217
|
const signal = reviewController?.signal;
|
|
201
218
|
const request = body => api.request(body, { signal });
|
|
202
|
-
const reviewOptions = { signal, providerReportingSupported: claim.capabilities?.reported_executor_provider === true,
|
|
219
|
+
const reviewOptions = { signal, historyScope, beforeAutomaticRetry: () => failureCircuit.providerRetryUsed(historyScope), allowAutomaticRetry: !failureCircuit.snapshot(historyScope).provider_recovery_pending, providerReportingSupported: claim.capabilities?.reported_executor_provider === true,
|
|
203
220
|
beforeLeaseDispatch: () => request({ action: "heartbeat", ...leaseBody(claim, provider) }) };
|
|
204
221
|
const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
|
|
205
222
|
? { reported_executor_provider: review.provider || selectedProvider } : {};
|
|
@@ -256,9 +273,11 @@ export function createSqmLocalReviewWorker({
|
|
|
256
273
|
const circuit = failureCircuit.snapshot(getHistoryScope());
|
|
257
274
|
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended,
|
|
258
275
|
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,
|
|
276
|
+
pause_reason: circuit.provider_retry_exhausted ? "provider_retry_failed_until_update" : circuit.paused_until_update ? "consecutive_failures_until_update" : circuit.storage_unavailable ? "failure_state_unavailable" : pauseReason,
|
|
260
277
|
...circuit, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
261
|
-
...(pause ? { provider_pause: { code: pause.code, status: pause.status,
|
|
278
|
+
...(pause ? { provider_pause: { code: pause.code, status: pause.status,
|
|
279
|
+
retry_at: circuit.provider_retry_exhausted ? null : new Date(pause.retryAt).toISOString(),
|
|
280
|
+
message: circuit.provider_retry_exhausted ? "The provider retry failed. New SQM reviews are paused until SIMY is updated." : pause.message } } : {}) };
|
|
262
281
|
},
|
|
263
282
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
|
|
264
283
|
desktopTaskDetail(key) {
|
|
@@ -270,8 +289,15 @@ export function createSqmLocalReviewWorker({
|
|
|
270
289
|
poll() {
|
|
271
290
|
if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
|
|
272
291
|
const environmentKey = getEnvironmentKey();
|
|
273
|
-
if (pausedEnvironment !== null && pausedEnvironment === environmentKey)
|
|
274
|
-
|
|
292
|
+
if (pausedEnvironment !== null && pausedEnvironment === environmentKey) {
|
|
293
|
+
const pause = currentPause();
|
|
294
|
+
// Known provider failures already have a bounded cooldown. Do not let
|
|
295
|
+
// the environment gate suppress their recovery probe indefinitely.
|
|
296
|
+
if (!pause?.recoverable || (pause.recoveryKey === getRecoveryKey(getProvider()) && now() < pause.retryAt)) return Promise.resolve(null);
|
|
297
|
+
pausedEnvironment = null; pauseReason = null;
|
|
298
|
+
} else if (pausedEnvironment !== null) {
|
|
299
|
+
pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null;
|
|
300
|
+
}
|
|
275
301
|
const provider = getProvider();
|
|
276
302
|
if (provider == null) return Promise.resolve(null);
|
|
277
303
|
assertProvider(provider);
|
|
@@ -286,7 +312,7 @@ export function createSqmLocalReviewWorker({
|
|
|
286
312
|
const workspaceUrl = getWorkspaceUrl();
|
|
287
313
|
reviewController = new AbortController();
|
|
288
314
|
pollPromise = (async () => {
|
|
289
|
-
if (!await failureCircuit.canClaim(historyScope)) return null;
|
|
315
|
+
if (!await failureCircuit.canClaim(historyScope, now())) return null;
|
|
290
316
|
if (reviewController.signal.aborted) throw providerFailure("analysis_cancelled");
|
|
291
317
|
const claim = await api.request({
|
|
292
318
|
action: "claim",
|
|
@@ -312,7 +338,7 @@ export function createSqmLocalReviewWorker({
|
|
|
312
338
|
return null;
|
|
313
339
|
}
|
|
314
340
|
try { await failureCircuit.failure(historyScope); } catch (storageError) { onError(storageError); }
|
|
315
|
-
pauseForFailure(error, provider, historyScope, recoveryKey);
|
|
341
|
+
try { await pauseForFailure(error, provider, historyScope, recoveryKey); } catch (storageError) { onError(storageError); }
|
|
316
342
|
if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
|
|
317
343
|
const scope = historyScope;
|
|
318
344
|
const failure = reviewFailure(error, "claim", provider);
|
|
@@ -322,7 +348,7 @@ export function createSqmLocalReviewWorker({
|
|
|
322
348
|
history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
|
|
323
349
|
await persist(scope, item);
|
|
324
350
|
}
|
|
325
|
-
if (["provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable", "provider_sign_in_required", "provider_status_unknown"].includes(error.code)) {
|
|
351
|
+
if (isRecoverableProviderFailure(failureDetails.get(error) || reviewFailure(error, "claim", provider)) || ["provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable", "provider_sign_in_required", "provider_status_unknown"].includes(error.code)) {
|
|
326
352
|
pausedEnvironment = environmentKey; pauseReason = error.code;
|
|
327
353
|
}
|
|
328
354
|
issueContext = key;
|
|
@@ -409,6 +435,10 @@ async function runSingleProviderReview(
|
|
|
409
435
|
terminationGraceMs = 1_000,
|
|
410
436
|
beforeDispatch = async () => {},
|
|
411
437
|
deadlineAt,
|
|
438
|
+
codexAttemptTimeoutMs = CODEX_ATTEMPT_TIMEOUT_MS,
|
|
439
|
+
minStallRetryMs = MIN_STALL_RETRY_MS,
|
|
440
|
+
allowAutomaticRetry = true,
|
|
441
|
+
beforeAutomaticRetry = async () => {},
|
|
412
442
|
} = {},
|
|
413
443
|
) {
|
|
414
444
|
assertProvider(provider);
|
|
@@ -435,7 +465,7 @@ async function runSingleProviderReview(
|
|
|
435
465
|
const models = provider === "codex" ? SQM_CODEX_MODELS : [null];
|
|
436
466
|
let processResult;
|
|
437
467
|
let selectedModel;
|
|
438
|
-
let modelIndex = 0, retryAttempt = 0, claudeTurnLimit = 1;
|
|
468
|
+
let modelIndex = 0, retryAttempt = 0, claudeTurnLimit = 1, stallRetried = false;
|
|
439
469
|
while (modelIndex < models.length) {
|
|
440
470
|
const model = models[modelIndex];
|
|
441
471
|
await beforeDispatch();
|
|
@@ -472,7 +502,7 @@ async function runSingleProviderReview(
|
|
|
472
502
|
"-",
|
|
473
503
|
],
|
|
474
504
|
prompt,
|
|
475
|
-
{ spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
|
|
505
|
+
{ spawnImpl, executionTimeoutMs: remaining, stallTimeoutMs: provider === "codex" && readOnlyAnalysis && allowAutomaticRetry && !stallRetried && remaining > codexAttemptTimeoutMs ? codexAttemptTimeoutMs : null, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
|
|
476
506
|
terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
|
|
477
507
|
);
|
|
478
508
|
selectedModel = model;
|
|
@@ -481,12 +511,19 @@ async function runSingleProviderReview(
|
|
|
481
511
|
// Repair only an explicit native structured-generation limit. Keep
|
|
482
512
|
// initial calls unchanged, the original deadline and dispatch guard,
|
|
483
513
|
// and never retry authentication/schema/process/security failures.
|
|
484
|
-
if (provider === "claude" && readOnlyAnalysis && retryAttempt === 0
|
|
514
|
+
if (allowAutomaticRetry && provider === "claude" && readOnlyAnalysis && retryAttempt === 0
|
|
485
515
|
&& ["provider_turn_limit_reached", "provider_structured_output_exhausted"].includes(error.code)) {
|
|
516
|
+
await beforeAutomaticRetry();
|
|
486
517
|
retryAttempt = 1;
|
|
487
518
|
if (error.code === "provider_turn_limit_reached") claudeTurnLimit = 3;
|
|
488
519
|
continue;
|
|
489
520
|
}
|
|
521
|
+
if (allowAutomaticRetry && provider === "codex" && readOnlyAnalysis && !stallRetried && error.code === "analysis_timeout"
|
|
522
|
+
&& ["before_turn", "turn_started"].includes(error.providerDiagnostic?.phase) && deadline - Date.now() >= minStallRetryMs) {
|
|
523
|
+
await beforeAutomaticRetry();
|
|
524
|
+
stallRetried = true; retryAttempt = 1;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
490
527
|
if (error.code !== "provider_model_unsupported" || model === models.at(-1)) throw error;
|
|
491
528
|
modelIndex += 1;
|
|
492
529
|
}
|
|
@@ -513,6 +550,7 @@ async function runSingleProviderReview(
|
|
|
513
550
|
return {
|
|
514
551
|
output,
|
|
515
552
|
model: provider === "codex" ? selectedModel : `${provider}-local/${inspection.installed_version || "unknown"}`,
|
|
553
|
+
...(processResult.diagnostic.retryAttempt === 1 ? { retryAttempt: 1 } : {}),
|
|
516
554
|
};
|
|
517
555
|
} catch (error) {
|
|
518
556
|
if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
|
|
@@ -537,7 +575,7 @@ function codexAnalysisArguments(system) {
|
|
|
537
575
|
'-c', `developer_instructions=${JSON.stringify(system)}`];
|
|
538
576
|
}
|
|
539
577
|
|
|
540
|
-
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt }) {
|
|
578
|
+
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, stallTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt }) {
|
|
541
579
|
return new Promise((resolve, reject) => {
|
|
542
580
|
if (signal?.aborted) return reject(providerFailure("analysis_cancelled"));
|
|
543
581
|
let child;
|
|
@@ -546,7 +584,7 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd
|
|
|
546
584
|
env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
|
|
547
585
|
});
|
|
548
586
|
} catch { reject(providerFailure("provider_spawn_failed", { model })); return; }
|
|
549
|
-
let stdout = "", stderrPending = "", outputBytes = 0;
|
|
587
|
+
let stdout = "", stderrPending = "", outputBytes = 0, phase = "before_turn";
|
|
550
588
|
let settled = false, terminalError = null, killTimer = null;
|
|
551
589
|
let diagnostic = safeProviderDiagnostic({ model, retryAttempt });
|
|
552
590
|
const halt = error => {
|
|
@@ -563,12 +601,18 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd
|
|
|
563
601
|
const cancelled = () => halt(providerFailure("analysis_cancelled"));
|
|
564
602
|
const timer = setTimeout(() => halt(providerFailure("analysis_timeout")), executionTimeoutMs);
|
|
565
603
|
timer.unref?.();
|
|
604
|
+
// The short watchdog applies only while the turn has produced no output.
|
|
605
|
+
// Once output arrives, the original total deadline still bounds the work.
|
|
606
|
+
const stallTimer = stallTimeoutMs == null ? null : setTimeout(() => {
|
|
607
|
+
if (phase !== "turn_output") halt(providerFailure("analysis_timeout"));
|
|
608
|
+
}, stallTimeoutMs);
|
|
609
|
+
stallTimer?.unref?.();
|
|
566
610
|
const finish = (error = null) => {
|
|
567
611
|
if (settled) return;
|
|
568
612
|
settled = true;
|
|
569
|
-
clearTimeout(timer); clearTimeout(killTimer);
|
|
613
|
+
clearTimeout(timer); clearTimeout(stallTimer); clearTimeout(killTimer);
|
|
570
614
|
signal?.removeEventListener("abort", cancelled);
|
|
571
|
-
diagnostic = safeProviderDiagnostic({ ...diagnostic, outputBytes });
|
|
615
|
+
diagnostic = safeProviderDiagnostic({ ...diagnostic, outputBytes, ...(provider === "codex" ? { phase } : {}) });
|
|
572
616
|
if (error) {
|
|
573
617
|
error.providerDiagnostic = safeProviderDiagnostic({ ...error.providerDiagnostic, ...diagnostic });
|
|
574
618
|
reject(error);
|
|
@@ -600,9 +644,14 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd
|
|
|
600
644
|
if (outputBytes > 2 * 1024 * 1024) { halt(providerFailure("provider_output_too_large")); return; }
|
|
601
645
|
stdout += chunk;
|
|
602
646
|
if (readOnlyAnalysis && provider === "codex") {
|
|
647
|
+
let observed = "before_turn";
|
|
603
648
|
for (const line of stdout.split("\n").filter(Boolean)) {
|
|
604
649
|
let event;
|
|
605
650
|
try { event = JSON.parse(line); } catch { continue; }
|
|
651
|
+
if (event.type === "turn.started") observed = observed === "before_turn" ? "turn_started" : observed;
|
|
652
|
+
else if (observed !== "before_turn" && ["turn.completed", "item.completed"].includes(event.type)) observed = "turn_output";
|
|
653
|
+
phase = observed;
|
|
654
|
+
if (phase === "turn_output") clearTimeout(stallTimer);
|
|
606
655
|
if (["error", "turn.failed"].includes(event.type)) {
|
|
607
656
|
const error = nativeFailure(event.message ?? event.error?.message);
|
|
608
657
|
if (error) { halt(error); return; }
|