@awak-app/simy-cli 0.5.73 → 0.5.74
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 +13 -0
- package/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/desktop-host.js +1 -0
- package/src/diagnostic-log.js +4 -0
- package/src/session-supervision/analysis-manager.js +45 -0
- package/src/session-supervision/continuation-analysis.js +1 -1
- package/src/sqm/history-store.js +6 -1
- package/src/sqm/provider-diagnostics.js +35 -0
- package/src/sqm/review-worker.js +160 -101
package/README.md
CHANGED
|
@@ -99,6 +99,19 @@ retains the authoritative analysis history. Persistent provider login, model or
|
|
|
99
99
|
access errors pause new local claims with a visible recovery hint; refreshing
|
|
100
100
|
after reconnecting permits another probe, with bounded automatic cooldowns.
|
|
101
101
|
|
|
102
|
+
Conversation checks blocked by **Sign-in required** automatically recheck after
|
|
103
|
+
a fresh Codex or Claude login probe succeeds, including after an app restart.
|
|
104
|
+
An unchanged ready status cannot repeatedly re-run a failed check. Manual retry
|
|
105
|
+
remains available; continuing the conversation still requires **Auto-continue**.
|
|
106
|
+
|
|
107
|
+
Claude read-only structured reviews retry once when the provider explicitly
|
|
108
|
+
reports exhausted turns or structured-output repair attempts, within the original
|
|
109
|
+
timeout. Other failures retain distinct codes and recovery hints. Safe native
|
|
110
|
+
result subtype, exit status, output size and recognized model identifiers survive
|
|
111
|
+
restart in SQM history and correlate with the task ID in diagnostic logs. Provider
|
|
112
|
+
responses, prompts and credentials are not retained. Older generic failure records
|
|
113
|
+
cannot acquire details that were never recorded.
|
|
114
|
+
|
|
102
115
|
Detached daemon and desktop-host diagnostics are saved to
|
|
103
116
|
`$SIMY_HOME/logs/daemon.log` and `$SIMY_HOME/logs/desktop-host.log`
|
|
104
117
|
(`SIMY_HOME` defaults to `~/.simy`). Each log is limited to 1 MiB plus two
|
package/package.json
CHANGED
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.74";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
package/src/desktop-host.js
CHANGED
|
@@ -231,6 +231,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
231
231
|
if (!closed && !relay) emitStatus(status);
|
|
232
232
|
return;
|
|
233
233
|
}
|
|
234
|
+
await analysisManager.updateProviderStatuses?.(status.providers);
|
|
234
235
|
const records = await recordReader.list();
|
|
235
236
|
const projection = await actionAssociations.beginProjection?.({ records });
|
|
236
237
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
package/src/diagnostic-log.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID, createHash } from "node:crypto";
|
|
|
2
2
|
import { mkdir, appendFile, rename, writeFile, readdir, readFile, rm } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { defaultSessionRoot } from "./session-store.js";
|
|
5
|
+
import { safeProviderDiagnostic } from "./sqm/provider-diagnostics.js";
|
|
5
6
|
|
|
6
7
|
const writers = new Map();
|
|
7
8
|
const scopeDir = (root, scope) => join(root, "diagnostics", createHash("sha256").update(JSON.stringify([
|
|
@@ -12,7 +13,10 @@ const safeIdentifier = value => typeof value === "string" && /^[a-z][a-z0-9_.-]{
|
|
|
12
13
|
// Only structured diagnostics are durable. Never retain prompts, arbitrary error
|
|
13
14
|
// messages, tokens, source paths, or provider stdout/stderr.
|
|
14
15
|
export function safeDiagnosticEvent(value, now = () => Date.now()) {
|
|
16
|
+
const providerDiagnostic = safeProviderDiagnostic(value?.provider_diagnostic);
|
|
15
17
|
return { at: Number.isFinite(Date.parse(value?.at)) ? value.at : new Date(now()).toISOString(),
|
|
18
|
+
...(/^(?:sqm-check:)?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value?.task_id || "") ? { task_id: value.task_id } : {}),
|
|
19
|
+
...(Object.keys(providerDiagnostic).length ? { provider_diagnostic: providerDiagnostic } : {}),
|
|
16
20
|
kind: ["analysis", "check", "preflight"].includes(value?.kind) ? value.kind : "check",
|
|
17
21
|
stage: safeIdentifier(value?.stage) || "unknown",
|
|
18
22
|
code: safeIdentifier(value?.code) || "failure_unclassified",
|
|
@@ -5,6 +5,7 @@ import { analyzeContinuation, CONTINUATION_ANALYSIS_VERSION } from './continuati
|
|
|
5
5
|
|
|
6
6
|
const BACKFILL_LIMIT = 10;
|
|
7
7
|
const MAX_CACHE_ENTRIES = 2000;
|
|
8
|
+
const AUTH_RECOVERY_COOLDOWN_MS = 60_000;
|
|
8
9
|
const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting', 'uncertain']);
|
|
9
10
|
const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
|
|
10
11
|
const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
|
|
@@ -49,6 +50,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
49
50
|
const known = new Map(document.known.slice(-MAX_CACHE_ENTRIES));
|
|
50
51
|
const entries = new Map(document.entries.slice(-MAX_CACHE_ENTRIES));
|
|
51
52
|
const candidates = new Map();
|
|
53
|
+
const providerStatuses = new Map(), signedOutChecks = new Map();
|
|
52
54
|
for (const [key, entry] of entries) {
|
|
53
55
|
if (entry.result?.state === 'analyzing') entry.result = { state: 'queued' };
|
|
54
56
|
}
|
|
@@ -79,9 +81,34 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
79
81
|
forceAnalysis: forceAnalysis || entry?.forceAnalysis === true && ['queued', 'analyzing'].includes(entry.result?.state),
|
|
80
82
|
settledResult: entry?.cwd === candidate.cwd ? entry.settledResult : null,
|
|
81
83
|
requestId: entry?.cwd === candidate.cwd && ['queued', 'analyzing'].includes(entry.result?.state) ? entry.requestId : undefined,
|
|
84
|
+
...(entry?.revision === revision ? { authRecoveryAttemptedAt: entry.authRecoveryAttemptedAt, authRecoverySignedOutAt: entry.authRecoverySignedOutAt } : {}),
|
|
82
85
|
result: { state: 'queued' } });
|
|
83
86
|
return true;
|
|
84
87
|
};
|
|
88
|
+
// A successful login probe must postdate the failed inference. Cache hits and
|
|
89
|
+
// opening a login terminal are not recovery evidence. One automatic attempt
|
|
90
|
+
// survives restarts; only a new observed sign-out/sign-in cycle can rearm it.
|
|
91
|
+
const recoverAuthentication = () => {
|
|
92
|
+
let dirty = false;
|
|
93
|
+
for (const [key, candidate] of candidates) {
|
|
94
|
+
const entry = entries.get(key), status = providerStatuses.get(candidate.provider);
|
|
95
|
+
const checkedAt = Date.parse(status?.checkedAt);
|
|
96
|
+
if (!eligible(candidate) || candidate.isRunning === true || jobs.has(key) ||
|
|
97
|
+
entry?.revision !== continuationAnalysisRevision(candidate) ||
|
|
98
|
+
!['uncertain', 'failed'].includes(entry.result?.state) || entry.result?.reasonCode !== 'provider_sign_in_required' ||
|
|
99
|
+
status?.state !== 'ready' || !Number.isFinite(checkedAt) || checkedAt <= (entry.completedAt || entry.result.analyzedAt || 0)) continue;
|
|
100
|
+
const signedOutAt = signedOutChecks.get(candidate.provider) || 0;
|
|
101
|
+
if (entry.authRecoveryAttemptedAt !== undefined &&
|
|
102
|
+
(signedOutAt <= (entry.authRecoverySignedOutAt || 0) || now() - entry.authRecoveryAttemptedAt < AUTH_RECOVERY_COOLDOWN_MS)) continue;
|
|
103
|
+
if (enqueue(candidate, true, true)) {
|
|
104
|
+
const queued = entries.get(key);
|
|
105
|
+
queued.authRecoveryAttemptedAt = now();
|
|
106
|
+
queued.authRecoverySignedOutAt = signedOutAt;
|
|
107
|
+
dirty = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return dirty;
|
|
111
|
+
};
|
|
85
112
|
const finishedAnalysis = (result, previous) => RESULT_STATES.has(result.state) && result.awaitingTurn !== true && result.contextDigest
|
|
86
113
|
? { ...result, awaitingTurn: false, analyzedAt: result.contextDigest && result.contextDigest === previous?.contextDigest ? previous.analyzedAt : now() }
|
|
87
114
|
: previous;
|
|
@@ -136,6 +163,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
136
163
|
}
|
|
137
164
|
const settledResult = finishedAnalysis(result, selected.previousAnalysis || entry.settledResult);
|
|
138
165
|
entries.set(selected.key, { revision: selected.revision, analysisVersion: CONTINUATION_ANALYSIS_VERSION, cwd: current.cwd, settledResult,
|
|
166
|
+
completedAt: now(), authRecoveryAttemptedAt: selected.entry.authRecoveryAttemptedAt, authRecoverySignedOutAt: selected.entry.authRecoverySignedOutAt,
|
|
139
167
|
requestId: selected.entry.requestId, result: { ...result, analyzedAt: settledResult?.analyzedAt || now() } });
|
|
140
168
|
await persist(); changed();
|
|
141
169
|
});
|
|
@@ -183,6 +211,22 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
183
211
|
});
|
|
184
212
|
};
|
|
185
213
|
return {
|
|
214
|
+
async updateProviderStatuses(statuses = {}) {
|
|
215
|
+
await exclusive(async () => {
|
|
216
|
+
if (closed) return;
|
|
217
|
+
for (const provider of ['codex', 'claude']) {
|
|
218
|
+
const status = statuses[provider];
|
|
219
|
+
if (!status) continue;
|
|
220
|
+
const checkedAt = Date.parse(status.checkedAt);
|
|
221
|
+
const previousAt = Date.parse(providerStatuses.get(provider)?.checkedAt);
|
|
222
|
+
if (Number.isFinite(previousAt) && (!Number.isFinite(checkedAt) || checkedAt < previousAt)) continue;
|
|
223
|
+
providerStatuses.set(provider, status);
|
|
224
|
+
if (status.state === 'signed_out' && Number.isFinite(checkedAt)) signedOutChecks.set(provider, checkedAt);
|
|
225
|
+
}
|
|
226
|
+
if (recoverAuthentication()) { await persist(); changed(); }
|
|
227
|
+
});
|
|
228
|
+
startDrain();
|
|
229
|
+
},
|
|
186
230
|
async update(sessions) {
|
|
187
231
|
await exclusive(async () => {
|
|
188
232
|
if (closed) return;
|
|
@@ -230,6 +274,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
230
274
|
}
|
|
231
275
|
// Empty discovery during provider startup must not consume backfill.
|
|
232
276
|
if (sorted.length) document.initialized = true;
|
|
277
|
+
dirty = recoverAuthentication() || dirty;
|
|
233
278
|
if (dirty) await persist();
|
|
234
279
|
});
|
|
235
280
|
startDrain();
|
|
@@ -64,7 +64,7 @@ async function unavailableAnalysis(candidate, error, inspectProvider, signal) {
|
|
|
64
64
|
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
|
|
65
65
|
if (error?.code === 'provider_sign_in_required' || status?.state === 'signed_out') {
|
|
66
66
|
const label = candidate.provider === 'claude' ? 'Claude Code' : 'Codex';
|
|
67
|
-
return uncertain(`Sign in to ${label}
|
|
67
|
+
return uncertain(`Sign in to ${label}. SIMY rechecks this conversation automatically when sign-in is detected. You can also retry manually. Unfinished work can continue automatically only when Auto Continue is on.`, 'provider_sign_in_required');
|
|
68
68
|
}
|
|
69
69
|
return uncertain('The local AI app could not check this turn. Check its connection, then retry.', 'analysis_unavailable');
|
|
70
70
|
}
|
package/src/sqm/history-store.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { diagnosticLogger } from "../diagnostic-log.js";
|
|
2
|
+
import { safeProviderDiagnostic } from "./provider-diagnostics.js";
|
|
2
3
|
import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
@@ -47,7 +48,9 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
|
|
|
47
48
|
await writeFile(temp, JSON.stringify(item), { mode: 0o600 });
|
|
48
49
|
await rename(temp, target);
|
|
49
50
|
if (["failed", "unavailable"].includes(item.state)) await diagnosticLogger({ root, scope }).record({
|
|
50
|
-
kind: item.kind, stage:
|
|
51
|
+
kind: item.kind, stage: item.errorStage || item.phase || "unknown", code: item.errorCode, provider: item.provider,
|
|
52
|
+
task_id: item.id, provider_diagnostic: item.providerDiagnostic,
|
|
53
|
+
http_status: item.errorStatus ?? item.httpStatus, at: item.finishedAt || item.startedAt });
|
|
51
54
|
} finally { await rm(temp, { force: true }); cache.delete(dir); }
|
|
52
55
|
const items = await list(scope);
|
|
53
56
|
for (const kind of ["analysis", "check"]) {
|
|
@@ -78,6 +81,8 @@ function metadata(entry) {
|
|
|
78
81
|
}
|
|
79
82
|
if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
|
|
80
83
|
if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
|
|
84
|
+
const providerDiagnostic = safeProviderDiagnostic(entry.providerDiagnostic);
|
|
85
|
+
if (Object.keys(providerDiagnostic).length) item.providerDiagnostic = providerDiagnostic;
|
|
81
86
|
return item;
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const SUBTYPES = new Set(["success", "error_max_turns", "error_during_execution", "error_max_budget_usd", "error_max_structured_output_retries", "unknown"]);
|
|
2
|
+
const SIGNALS = new Set(["SIGTERM", "SIGKILL", "SIGABRT", "SIGSEGV", "SIGINT", "SIGHUP", "SIGPIPE"]);
|
|
3
|
+
|
|
4
|
+
// Reuse at every persistence boundary. Provider envelopes may contain prompts,
|
|
5
|
+
// paths, request IDs or credentials; none of their free-form text is retained.
|
|
6
|
+
export function safeProviderDiagnostic(value) {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
8
|
+
const result = {};
|
|
9
|
+
if (typeof value.subtype === "string") result.subtype = SUBTYPES.has(value.subtype) ? value.subtype : "unknown";
|
|
10
|
+
if (Number.isInteger(value.exitCode) && value.exitCode >= 0 && value.exitCode <= 255) result.exitCode = value.exitCode;
|
|
11
|
+
if (SIGNALS.has(value.signal)) result.signal = value.signal;
|
|
12
|
+
if (Number.isInteger(value.outputBytes) && value.outputBytes >= 0 && value.outputBytes <= 2147483647) result.outputBytes = value.outputBytes;
|
|
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;
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const PROVIDER_FAILURE_HINTS = Object.freeze({
|
|
19
|
+
provider_turn_limit_reached: "The local AI reached its turn limit before completing structured output. Retry this analysis; if it repeats, update the local AI app or report this error code.",
|
|
20
|
+
provider_structured_output_exhausted: "The local AI exhausted its structured-output repair attempts. Retry the analysis; if it repeats, report this error code and update the local AI app.",
|
|
21
|
+
provider_budget_exceeded: "The local AI stopped at its configured budget. Check that provider's budget before retrying.",
|
|
22
|
+
provider_execution_error: "The local AI reported an execution error. Check its local status and retry the analysis.",
|
|
23
|
+
provider_invalid_json: "The local AI returned invalid JSON. Retry the analysis; if it repeats, update the local AI app.",
|
|
24
|
+
provider_invalid_envelope: "The local AI returned an unsupported response envelope. Update the local AI app and retry.",
|
|
25
|
+
provider_missing_structured_output: "The local AI returned no schema-constrained output. Update the local AI app and retry this analysis.",
|
|
26
|
+
provider_non_object_output: "The local AI returned a structured value that was not an object. Retry this analysis; if it repeats, report this error code.",
|
|
27
|
+
provider_process_failed: "The local AI process exited before completing the review. Check the recorded exit code and the local AI app, then retry.",
|
|
28
|
+
provider_spawn_failed: "The local AI process could not start. Check its installation and local execution permissions.",
|
|
29
|
+
provider_output_too_large: "The local AI response exceeded the output-size limit. Reduce the evidence size before retrying.",
|
|
30
|
+
provider_network_unavailable: "The local AI could not reach its provider. Check its network or proxy connection, then retry.",
|
|
31
|
+
provider_schema_rejected: "The local AI rejected the output schema. Update the local AI app; if it repeats, report this error code.",
|
|
32
|
+
provider_rate_limited: "The local AI provider is rate limited. Wait for its limit to reset before retrying.",
|
|
33
|
+
provider_tool_use_blocked: "The local AI attempted to use a tool during a read-only analysis. The analysis was stopped.",
|
|
34
|
+
analysis_cancelled: "The local AI analysis was cancelled.",
|
|
35
|
+
});
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { inspectProviderStatus } from "../desktop-status.js";
|
|
7
7
|
import { inspectDesktopExecutor } from "../desktop-executor.js";
|
|
8
|
+
import { safeProviderDiagnostic, PROVIDER_FAILURE_HINTS } from "./provider-diagnostics.js";
|
|
8
9
|
import {
|
|
9
10
|
webApiErrorMessage,
|
|
10
11
|
webApiHeaders,
|
|
@@ -21,13 +22,14 @@ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
|
|
|
21
22
|
export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
|
|
22
23
|
|
|
23
24
|
export class SqmLocalReviewError extends Error {
|
|
24
|
-
constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false, validationFeedback = "" } = {}) {
|
|
25
|
+
constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false, validationFeedback = "", providerDiagnostic } = {}) {
|
|
25
26
|
super(message);
|
|
26
27
|
this.name = "SqmLocalReviewError";
|
|
27
28
|
this.status = status;
|
|
28
29
|
this.code = code;
|
|
29
30
|
this.stage = stage; this.jobId = jobId; this.failureRecorded = failureRecorded;
|
|
30
31
|
this.validationFeedback = typeof validationFeedback === "string" ? validationFeedback.slice(0, 2000) : "";
|
|
32
|
+
if (providerDiagnostic) this.providerDiagnostic = safeProviderDiagnostic(providerDiagnostic);
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -134,7 +136,7 @@ export function createSqmLocalReviewWorker({
|
|
|
134
136
|
item.state = state; item.finishedAt = new Date().toISOString();
|
|
135
137
|
item.result = state === "completed" ? "SQM review submitted." : "";
|
|
136
138
|
item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
|
|
137
|
-
if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; }
|
|
139
|
+
if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; item.providerDiagnostic = failure.providerDiagnostic; }
|
|
138
140
|
await persist(scope, item);
|
|
139
141
|
}
|
|
140
142
|
onChange();
|
|
@@ -379,9 +381,11 @@ async function runSingleProviderReview(
|
|
|
379
381
|
].join("\n\n");
|
|
380
382
|
const deadline = deadlineAt ?? Date.now() + executionTimeoutMs;
|
|
381
383
|
const models = provider === "codex" ? SQM_CODEX_MODELS : [null];
|
|
382
|
-
let
|
|
384
|
+
let processResult;
|
|
383
385
|
let selectedModel;
|
|
384
|
-
|
|
386
|
+
let modelIndex = 0, retryAttempt = 0, claudeTurnLimit = 1;
|
|
387
|
+
while (modelIndex < models.length) {
|
|
388
|
+
const model = models[modelIndex];
|
|
385
389
|
await beforeDispatch();
|
|
386
390
|
if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
|
|
387
391
|
const remaining = deadline - Date.now();
|
|
@@ -389,7 +393,7 @@ async function runSingleProviderReview(
|
|
|
389
393
|
// Never consume a partial/stale output file left by a rejected model.
|
|
390
394
|
await rm(outputPath, { force: true });
|
|
391
395
|
try {
|
|
392
|
-
|
|
396
|
+
processResult = await spawnReview(
|
|
393
397
|
inspection.executable,
|
|
394
398
|
provider === "claude" ? [
|
|
395
399
|
"--print", "--output-format", "json",
|
|
@@ -397,7 +401,7 @@ async function runSingleProviderReview(
|
|
|
397
401
|
"--no-session-persistence", "--tools", "",
|
|
398
402
|
"--setting-sources", "", "--settings", '{"disableAllHooks":true}',
|
|
399
403
|
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
400
|
-
...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns",
|
|
404
|
+
...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", String(claudeTurnLimit)] : []),
|
|
401
405
|
] : [
|
|
402
406
|
"exec",
|
|
403
407
|
"--model",
|
|
@@ -416,27 +420,43 @@ async function runSingleProviderReview(
|
|
|
416
420
|
"-",
|
|
417
421
|
],
|
|
418
422
|
prompt,
|
|
419
|
-
{ spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model,
|
|
423
|
+
{ spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
|
|
420
424
|
terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
|
|
421
425
|
);
|
|
422
426
|
selectedModel = model;
|
|
423
427
|
break;
|
|
424
428
|
} catch (error) {
|
|
429
|
+
// Repair only an explicit native structured-generation limit. Keep
|
|
430
|
+
// initial calls unchanged, the original deadline and dispatch guard,
|
|
431
|
+
// and never retry authentication/schema/process/security failures.
|
|
432
|
+
if (provider === "claude" && readOnlyAnalysis && retryAttempt === 0
|
|
433
|
+
&& ["provider_turn_limit_reached", "provider_structured_output_exhausted"].includes(error.code)) {
|
|
434
|
+
retryAttempt = 1;
|
|
435
|
+
if (error.code === "provider_turn_limit_reached") claudeTurnLimit = 3;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
425
438
|
if (error.code !== "provider_model_unsupported" || model === models.at(-1)) throw error;
|
|
439
|
+
modelIndex += 1;
|
|
426
440
|
}
|
|
427
441
|
}
|
|
428
|
-
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
|
|
442
|
+
let output;
|
|
443
|
+
if (provider === "claude") {
|
|
444
|
+
const envelope = parseProviderJson(processResult.stdout, processResult.diagnostic);
|
|
445
|
+
const failure = claudeEnvelopeFailure(envelope, processResult.diagnostic);
|
|
446
|
+
if (failure) throw failure;
|
|
447
|
+
if (!Object.hasOwn(envelope, "structured_output")) throw providerFailure("provider_missing_structured_output", processResult.diagnostic);
|
|
448
|
+
output = envelope.structured_output;
|
|
449
|
+
} else {
|
|
450
|
+
let text;
|
|
451
|
+
try { text = await readFile(outputPath, "utf8"); }
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (error.code === "ENOENT") throw providerFailure("provider_missing_structured_output", processResult.diagnostic);
|
|
454
|
+
throw error;
|
|
432
455
|
}
|
|
433
|
-
|
|
456
|
+
output = parseProviderJson(text, processResult.diagnostic);
|
|
434
457
|
}
|
|
435
|
-
const output = provider === "claude"
|
|
436
|
-
? envelope?.structured_output
|
|
437
|
-
: JSON.parse(await readFile(outputPath, "utf8"));
|
|
438
458
|
if (!output || Array.isArray(output) || typeof output !== "object") {
|
|
439
|
-
throw
|
|
459
|
+
throw providerFailure("provider_non_object_output", processResult.diagnostic);
|
|
440
460
|
}
|
|
441
461
|
return {
|
|
442
462
|
output,
|
|
@@ -444,7 +464,7 @@ async function runSingleProviderReview(
|
|
|
444
464
|
};
|
|
445
465
|
} catch (error) {
|
|
446
466
|
if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
|
|
447
|
-
throw new SqmLocalReviewError(`Local ${label} review failed
|
|
467
|
+
throw new SqmLocalReviewError(`Local ${label} review failed before producing a structured result.`, {
|
|
448
468
|
code: `${provider}_local_review_failed`,
|
|
449
469
|
});
|
|
450
470
|
} finally {
|
|
@@ -465,120 +485,156 @@ function codexAnalysisArguments(system) {
|
|
|
465
485
|
'-c', `developer_instructions=${JSON.stringify(system)}`];
|
|
466
486
|
}
|
|
467
487
|
|
|
468
|
-
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs,
|
|
488
|
+
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt }) {
|
|
469
489
|
return new Promise((resolve, reject) => {
|
|
470
|
-
if (signal?.aborted) return reject(
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
});
|
|
477
|
-
let stdout = "";
|
|
478
|
-
let
|
|
479
|
-
let
|
|
480
|
-
let settled = false;
|
|
481
|
-
let terminalError = null;
|
|
482
|
-
let killTimer = null;
|
|
490
|
+
if (signal?.aborted) return reject(providerFailure("analysis_cancelled"));
|
|
491
|
+
let child;
|
|
492
|
+
try {
|
|
493
|
+
child = spawnImpl(command, args, {
|
|
494
|
+
env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
|
|
495
|
+
});
|
|
496
|
+
} catch { reject(providerFailure("provider_spawn_failed", { model })); return; }
|
|
497
|
+
let stdout = "", stderrPending = "", outputBytes = 0;
|
|
498
|
+
let settled = false, terminalError = null, killTimer = null;
|
|
499
|
+
let diagnostic = safeProviderDiagnostic({ model, retryAttempt });
|
|
483
500
|
const halt = error => {
|
|
484
501
|
if (settled || terminalError) return;
|
|
485
502
|
terminalError = error;
|
|
486
|
-
child.kill?.(
|
|
503
|
+
child.kill?.("SIGTERM");
|
|
487
504
|
if (!settled) killTimer = setTimeout(() => {
|
|
488
|
-
child.kill?.(
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
if (!settled) killTimer = setTimeout(() => finish(
|
|
505
|
+
child.kill?.("SIGKILL");
|
|
506
|
+
// Keep the original timeout/cancellation if the process API never
|
|
507
|
+
// acknowledges termination; do not replace it with an opaque error.
|
|
508
|
+
if (!settled) killTimer = setTimeout(() => finish(terminalError), terminationGraceMs);
|
|
492
509
|
}, terminationGraceMs);
|
|
493
510
|
};
|
|
494
|
-
const cancelled = () => halt(
|
|
495
|
-
const timer = setTimeout(() =>
|
|
496
|
-
halt(readOnlyAnalysis ? new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' }) : new Error(`${label} timed out after ${executionTimeoutMs}ms`));
|
|
497
|
-
}, executionTimeoutMs);
|
|
511
|
+
const cancelled = () => halt(providerFailure("analysis_cancelled"));
|
|
512
|
+
const timer = setTimeout(() => halt(providerFailure("analysis_timeout")), executionTimeoutMs);
|
|
498
513
|
timer.unref?.();
|
|
499
514
|
const finish = (error = null) => {
|
|
500
515
|
if (settled) return;
|
|
501
516
|
settled = true;
|
|
502
|
-
clearTimeout(timer);
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
517
|
+
clearTimeout(timer); clearTimeout(killTimer);
|
|
518
|
+
signal?.removeEventListener("abort", cancelled);
|
|
519
|
+
diagnostic = safeProviderDiagnostic({ ...diagnostic, outputBytes });
|
|
520
|
+
if (error) {
|
|
521
|
+
error.providerDiagnostic = safeProviderDiagnostic({ ...error.providerDiagnostic, ...diagnostic });
|
|
522
|
+
reject(error);
|
|
523
|
+
} else resolve({ stdout, diagnostic });
|
|
524
|
+
};
|
|
525
|
+
const nativeFailure = message => {
|
|
526
|
+
if (providerSignInRequired(message)) return providerFailure("provider_sign_in_required");
|
|
527
|
+
if (provider === "codex" && providerModelUnsupported(message, model)) return modelUnsupportedError(model);
|
|
528
|
+
const code = nativeProviderFailureCode(message);
|
|
529
|
+
return code ? providerFailure(code) : null;
|
|
530
|
+
};
|
|
531
|
+
const checkStderr = line => {
|
|
532
|
+
// Only native CLI error lines, never arbitrary warnings or model prose.
|
|
533
|
+
const match = line.match(/^\s*(?:ERROR:|Error:|API Error:)\s*(.*)$/);
|
|
534
|
+
if (!match) return;
|
|
535
|
+
let message = match[1];
|
|
536
|
+
try {
|
|
537
|
+
const event = JSON.parse(message);
|
|
538
|
+
if (event.type !== "error") return;
|
|
539
|
+
message = event.error?.message ?? event.message;
|
|
540
|
+
} catch { /* Older CLIs print a plain native ERROR line. */ }
|
|
541
|
+
const error = nativeFailure(message);
|
|
542
|
+
if (error) halt(error);
|
|
506
543
|
};
|
|
507
544
|
child.stdout?.setEncoding?.("utf8");
|
|
508
|
-
child.stdout?.on?.("data",
|
|
509
|
-
if (settled) return;
|
|
545
|
+
child.stdout?.on?.("data", chunk => {
|
|
546
|
+
if (settled || terminalError) return;
|
|
547
|
+
outputBytes = Math.min(2147483647, outputBytes + Buffer.byteLength(chunk, "utf8"));
|
|
548
|
+
if (outputBytes > 2 * 1024 * 1024) { halt(providerFailure("provider_output_too_large")); return; }
|
|
510
549
|
stdout += chunk;
|
|
511
|
-
if (readOnlyAnalysis && provider ===
|
|
512
|
-
for (const line of stdout.split(
|
|
550
|
+
if (readOnlyAnalysis && provider === "codex") {
|
|
551
|
+
for (const line of stdout.split("\n").filter(Boolean)) {
|
|
513
552
|
let event;
|
|
514
553
|
try { event = JSON.parse(line); } catch { continue; }
|
|
515
|
-
if ([
|
|
516
|
-
const
|
|
517
|
-
if (
|
|
518
|
-
halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
if (providerModelUnsupported(message, model)) {
|
|
522
|
-
halt(modelUnsupportedError(model));
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
554
|
+
if (["error", "turn.failed"].includes(event.type)) {
|
|
555
|
+
const error = nativeFailure(event.message ?? event.error?.message);
|
|
556
|
+
if (error) { halt(error); return; }
|
|
525
557
|
}
|
|
526
|
-
if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type ||
|
|
527
|
-
halt(
|
|
528
|
-
return;
|
|
558
|
+
if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || "")) {
|
|
559
|
+
halt(providerFailure("provider_tool_use_blocked")); return;
|
|
529
560
|
}
|
|
530
561
|
}
|
|
531
562
|
}
|
|
532
|
-
if (Buffer.byteLength(stdout, "utf8") > 2 * 1024 * 1024) {
|
|
533
|
-
halt(new Error(`${label} structured output exceeded the size limit`));
|
|
534
|
-
}
|
|
535
563
|
});
|
|
536
|
-
child.stdin?.on?.("error",
|
|
564
|
+
child.stdin?.on?.("error", () => halt(providerFailure("provider_process_failed")));
|
|
537
565
|
child.stderr?.setEncoding?.("utf8");
|
|
538
|
-
child.stderr?.on?.("data",
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
for (const line of lines) checkStderr(line);
|
|
545
|
-
}
|
|
566
|
+
child.stderr?.on?.("data", chunk => {
|
|
567
|
+
if (settled || terminalError) return;
|
|
568
|
+
stderrPending = `${stderrPending}${chunk}`.slice(-8000);
|
|
569
|
+
const lines = stderrPending.split(/\r?\n/);
|
|
570
|
+
stderrPending = lines.pop() || "";
|
|
571
|
+
for (const line of lines) checkStderr(line);
|
|
546
572
|
});
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
try {
|
|
553
|
-
const event = JSON.parse(message);
|
|
554
|
-
if (event.type !== "error") return;
|
|
555
|
-
message = event.error?.message ?? event.message;
|
|
556
|
-
} catch { /* Older CLIs print a plain native ERROR line. */ }
|
|
557
|
-
if (providerSignInRequired(message)) {
|
|
558
|
-
halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
|
|
559
|
-
} else if (providerModelUnsupported(message, model)) {
|
|
560
|
-
halt(modelUnsupportedError(model));
|
|
561
|
-
}
|
|
562
|
-
};
|
|
563
|
-
child.on("error", finish);
|
|
564
|
-
child.on("close", (code) => {
|
|
565
|
-
if (provider === "codex") checkStderr(stderrPending);
|
|
566
|
-
if (readOnlyAnalysis && provider === 'claude' && !terminalError) {
|
|
573
|
+
child.on("error", () => finish(providerFailure("provider_spawn_failed")));
|
|
574
|
+
child.on("close", (exitCode, exitSignal) => {
|
|
575
|
+
diagnostic = safeProviderDiagnostic({ ...diagnostic, exitCode, signal: exitSignal });
|
|
576
|
+
checkStderr(stderrPending);
|
|
577
|
+
if (provider === "claude") {
|
|
567
578
|
let envelope;
|
|
568
|
-
try { envelope = JSON.parse(stdout); } catch { /*
|
|
569
|
-
if (envelope
|
|
570
|
-
|
|
571
|
-
|
|
579
|
+
try { envelope = JSON.parse(stdout); } catch { /* Classified after a successful process exit. */ }
|
|
580
|
+
if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) {
|
|
581
|
+
const models = Object.keys(envelope.modelUsage || {});
|
|
582
|
+
diagnostic = safeProviderDiagnostic({ ...diagnostic, subtype: envelope.subtype,
|
|
583
|
+
model: models.length === 1 ? models[0] : envelope.model });
|
|
584
|
+
terminalError ||= claudeEnvelopeFailure(envelope, diagnostic);
|
|
572
585
|
}
|
|
573
586
|
}
|
|
574
|
-
finish(terminalError || (
|
|
587
|
+
finish(terminalError || (exitCode === 0 ? null : providerFailure("provider_process_failed")));
|
|
575
588
|
});
|
|
576
|
-
signal?.addEventListener(
|
|
589
|
+
signal?.addEventListener("abort", cancelled, { once: true });
|
|
577
590
|
if (signal?.aborted) { cancelled(); return; }
|
|
578
591
|
child.stdin?.end(prompt);
|
|
579
592
|
});
|
|
580
593
|
}
|
|
581
594
|
|
|
595
|
+
function providerFailure(code, providerDiagnostic) {
|
|
596
|
+
return new SqmLocalReviewError(PROVIDER_FAILURE_HINTS[code]
|
|
597
|
+
|| (code === "provider_sign_in_required" ? "Sign in to the local AI app again." : "The local AI analysis exceeded its time limit."),
|
|
598
|
+
{ code, providerDiagnostic });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function parseProviderJson(text, diagnostic) {
|
|
602
|
+
try { return JSON.parse(text); }
|
|
603
|
+
catch { throw providerFailure("provider_invalid_json", diagnostic); }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function claudeEnvelopeFailure(envelope, diagnostic) {
|
|
607
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return providerFailure("provider_invalid_envelope", diagnostic);
|
|
608
|
+
if (typeof envelope.subtype !== "string" || (Object.hasOwn(envelope, "is_error") && typeof envelope.is_error !== "boolean")) return providerFailure("provider_invalid_envelope", diagnostic);
|
|
609
|
+
if (envelope.is_error === true || envelope.subtype !== "success") {
|
|
610
|
+
// Only inspect native failure envelopes, never a successful model result.
|
|
611
|
+
const messages = [envelope.result, ...(Array.isArray(envelope.errors) ? envelope.errors.slice(0, 8) : [])];
|
|
612
|
+
for (const message of messages) if (providerSignInRequired(message)) return providerFailure("provider_sign_in_required", diagnostic);
|
|
613
|
+
for (const message of messages) {
|
|
614
|
+
const code = nativeProviderFailureCode(message);
|
|
615
|
+
if (code) return providerFailure(code, diagnostic);
|
|
616
|
+
}
|
|
617
|
+
// An explicit auth/schema/network cause takes precedence over a limit
|
|
618
|
+
// subtype, so a mixed failure envelope cannot trigger a semantic retry.
|
|
619
|
+
const subtypeCode = {
|
|
620
|
+
error_max_turns: "provider_turn_limit_reached", error_max_budget_usd: "provider_budget_exceeded",
|
|
621
|
+
error_max_structured_output_retries: "provider_structured_output_exhausted",
|
|
622
|
+
}[envelope.subtype];
|
|
623
|
+
if (subtypeCode) return providerFailure(subtypeCode, diagnostic);
|
|
624
|
+
return providerFailure("provider_execution_error", diagnostic);
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function nativeProviderFailureCode(message) {
|
|
630
|
+
if (typeof message !== "string") return null;
|
|
631
|
+
const text = message.slice(0, 4000);
|
|
632
|
+
if (/\b(?:invalid|unsupported)\s+(?:json\s+|output\s+)?schema\b|\bschema validation (?:failed|error)\b/i.test(text)) return "provider_schema_rejected";
|
|
633
|
+
if (/\b429\b.*(?:rate|limit)|\brate limit(?:ed| exceeded)\b/i.test(text)) return "provider_rate_limited";
|
|
634
|
+
if (/^(?:API Error:\s*)?(?:Connection error\b|(?:connect )?(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN)\b|fetch failed\b|network (?:error|unavailable)\b)/i.test(text.trim())) return "provider_network_unavailable";
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
582
638
|
function providerModelUnsupported(message, model) {
|
|
583
639
|
if (typeof message !== "string" || typeof model !== "string") return false;
|
|
584
640
|
const rejection = message.trim().match(/^The ['"]([^'"]+)['"] model is not supported(?: when using Codex with a ChatGPT account)?\.?$/i);
|
|
@@ -648,7 +704,8 @@ function reviewFailure(error, stage, provider) {
|
|
|
648
704
|
else if (stage === "analysis" || stage === "adjudication") code = `${provider}_local_review_failed`;
|
|
649
705
|
}
|
|
650
706
|
return { stage: /^[a-z_]{1,40}$/.test(error?.stage || "") ? error.stage : stage, code,
|
|
651
|
-
status: Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null
|
|
707
|
+
status: Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null,
|
|
708
|
+
providerDiagnostic: safeProviderDiagnostic(error?.providerDiagnostic) };
|
|
652
709
|
}
|
|
653
710
|
|
|
654
711
|
function formatFailure(failure, failureReported = null) {
|
|
@@ -660,9 +717,11 @@ function formatFailure(failure, failureReported = null) {
|
|
|
660
717
|
: failure.status === 403 || failure.code === "desktop_access_required" ? "Check SQM and Developer access for the selected organization."
|
|
661
718
|
: failure.status === 401 ? "Reconnect your SIMY session."
|
|
662
719
|
: failure.code.endsWith("_local_unavailable") ? "Install or update the selected local AI app."
|
|
720
|
+
: PROVIDER_FAILURE_HINTS[failure.code] ? PROVIDER_FAILURE_HINTS[failure.code]
|
|
663
721
|
: failure.code.endsWith("_local_review_failed") ? "The local AI app did not complete a valid structured review."
|
|
664
722
|
: "Open analysis details to inspect the task state.";
|
|
665
|
-
|
|
723
|
+
const diagnostic = Object.entries(safeProviderDiagnostic(failure.providerDiagnostic)).map(([key, value]) => `${key}=${value}`).join(" · ");
|
|
724
|
+
return `${failure.stage} · ${failure.code}${failure.status ? ` · HTTP ${failure.status}` : ""}\n${hint}${failure.providerDiagnostic?.retryAttempt === 1 ? "\nOne automatic retry was attempted." : ""}${diagnostic ? `\n${diagnostic}` : ""}${failureReported === false ? "\nThe failure could not be reported to SIMY Web. The web task may still show as analyzing until its lease expires." : ""}`;
|
|
666
725
|
}
|
|
667
726
|
|
|
668
727
|
function analysisTaskUrl(workspaceUrl, id) {
|