@awak-app/simy-cli 0.5.72 → 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 +97 -0
- package/package.json +2 -2
- package/src/agent.js +191 -11
- package/src/backend-executable.js +21 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +22 -0
- package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
- package/src/coding-run-recovery.js +88 -0
- package/src/coding-run-store.js +2 -2
- package/src/continuation-policy.js +2 -0
- package/src/delivery-hooks/command.js +11 -5
- package/src/delivery-hooks/desktop.js +12 -3
- package/src/delivery-hooks/supervisor.js +14 -10
- package/src/desktop-host.js +9 -6
- package/src/desktop-sqm-settings.js +24 -7
- package/src/device-signing-identity.js +65 -0
- package/src/diagnostic-log.js +72 -0
- package/src/doctor.js +55 -0
- package/src/index.js +7 -0
- package/src/local-task.js +68 -3
- package/src/orchestrator/budget.js +55 -4
- package/src/orchestrator/charter-validation.js +33 -0
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
- package/src/orchestrator/independent-audit.js +9 -0
- package/src/orchestrator/instruction.js +6 -1
- package/src/orchestrator/loop.js +38 -41
- package/src/orchestrator/outcome-monitor.js +58 -38
- package/src/orchestrator/outcome-progress.js +2 -0
- package/src/purpose-local-proof.js +380 -0
- package/src/runner.js +224 -36
- package/src/session-supervision/analysis-manager.js +45 -0
- package/src/session-supervision/codex-app-server.js +3 -2
- package/src/session-supervision/continuation-analysis.js +1 -1
- package/src/session-supervision/index.js +16 -4
- package/src/sqm/bundle-store.js +11 -7
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +36 -4
- package/src/sqm/diagnostics.js +1 -1
- package/src/sqm/evidence-client.js +23 -7
- package/src/sqm/history-store.js +9 -0
- package/src/sqm/index.js +104 -14
- package/src/sqm/proof.js +3 -23
- package/src/sqm/provider-diagnostics.js +35 -0
- package/src/sqm/request-recovery.js +82 -0
- package/src/sqm/review-worker.js +218 -112
- package/src/sqm/session.js +33 -3
- package/src/startup-preflight.js +44 -0
package/src/sqm/review-worker.js
CHANGED
|
@@ -3,7 +3,9 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
+
import { inspectProviderStatus } from "../desktop-status.js";
|
|
6
7
|
import { inspectDesktopExecutor } from "../desktop-executor.js";
|
|
8
|
+
import { safeProviderDiagnostic, PROVIDER_FAILURE_HINTS } from "./provider-diagnostics.js";
|
|
7
9
|
import {
|
|
8
10
|
webApiErrorMessage,
|
|
9
11
|
webApiHeaders,
|
|
@@ -20,13 +22,14 @@ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
|
|
|
20
22
|
export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
|
|
21
23
|
|
|
22
24
|
export class SqmLocalReviewError extends Error {
|
|
23
|
-
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 } = {}) {
|
|
24
26
|
super(message);
|
|
25
27
|
this.name = "SqmLocalReviewError";
|
|
26
28
|
this.status = status;
|
|
27
29
|
this.code = code;
|
|
28
30
|
this.stage = stage; this.jobId = jobId; this.failureRecorded = failureRecorded;
|
|
29
31
|
this.validationFeedback = typeof validationFeedback === "string" ? validationFeedback.slice(0, 2000) : "";
|
|
32
|
+
if (providerDiagnostic) this.providerDiagnostic = safeProviderDiagnostic(providerDiagnostic);
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
|
|
@@ -88,7 +91,8 @@ export function createSqmLocalReviewWorker({
|
|
|
88
91
|
leaseSeconds = DEFAULT_LEASE_SECONDS,
|
|
89
92
|
heartbeatIntervalMs = Math.max(10_000, Math.floor(leaseSeconds * 500)),
|
|
90
93
|
getProvider = () => "codex",
|
|
91
|
-
runReview = (request, provider) => runStructuredProviderReview(provider, request),
|
|
94
|
+
runReview = (request, provider, options) => runStructuredProviderReview(provider, request, { ...options, beforeDispatch: options?.beforeLeaseDispatch }),
|
|
95
|
+
getEnvironmentKey = () => null,
|
|
92
96
|
beginWork = () => () => {},
|
|
93
97
|
onError = () => {},
|
|
94
98
|
onChange = () => {},
|
|
@@ -104,7 +108,7 @@ export function createSqmLocalReviewWorker({
|
|
|
104
108
|
let activeJobId = null;
|
|
105
109
|
let pollPromise = null;
|
|
106
110
|
let closed = false;
|
|
107
|
-
let issue = null;
|
|
111
|
+
let issue = null, pausedEnvironment = null, pauseReason = null;
|
|
108
112
|
let issueContext = null;
|
|
109
113
|
const failureDetails = new WeakMap();
|
|
110
114
|
const pauses = new Map();
|
|
@@ -132,7 +136,7 @@ export function createSqmLocalReviewWorker({
|
|
|
132
136
|
item.state = state; item.finishedAt = new Date().toISOString();
|
|
133
137
|
item.result = state === "completed" ? "SQM review submitted." : "";
|
|
134
138
|
item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
|
|
135
|
-
if (failure) { item.phase = failure.stage; item.errorCode = failure.code; }
|
|
139
|
+
if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; item.providerDiagnostic = failure.providerDiagnostic; }
|
|
136
140
|
await persist(scope, item);
|
|
137
141
|
}
|
|
138
142
|
onChange();
|
|
@@ -148,11 +152,11 @@ export function createSqmLocalReviewWorker({
|
|
|
148
152
|
|
|
149
153
|
// Repair only a rejected model result, under the same live lease. Network,
|
|
150
154
|
// authorization, and persistence failures must not repeat provider execution.
|
|
151
|
-
const validatedReview = async (request, provider, code, submit, setStage) => {
|
|
155
|
+
const validatedReview = async (request, provider, code, submit, setStage, reviewOptions) => {
|
|
152
156
|
let nextRequest = request;
|
|
153
157
|
for (let attempt = 0; ; attempt += 1) {
|
|
154
158
|
setStage(false);
|
|
155
|
-
const review = await runReview(nextRequest, provider);
|
|
159
|
+
const review = await runReview(nextRequest, provider, reviewOptions);
|
|
156
160
|
setStage(true);
|
|
157
161
|
try {
|
|
158
162
|
return { review, response: await submit(review) };
|
|
@@ -180,22 +184,33 @@ export function createSqmLocalReviewWorker({
|
|
|
180
184
|
heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
|
|
181
185
|
heartbeatTimer.unref?.();
|
|
182
186
|
let stage = "analysis";
|
|
187
|
+
const reviewOptions = { providerReportingSupported: claim.capabilities?.reported_executor_provider === true,
|
|
188
|
+
beforeLeaseDispatch: () => api.request({ action: "heartbeat", ...leaseBody(claim, provider) }) };
|
|
189
|
+
const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
|
|
190
|
+
? { reported_executor_provider: review.provider || selectedProvider } : {};
|
|
183
191
|
try {
|
|
184
192
|
const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => api.request({
|
|
185
193
|
action: "advance",
|
|
186
194
|
...leaseBody(claim, provider),
|
|
187
195
|
analysis_output: analysis.output,
|
|
188
196
|
executor_model: analysis.model,
|
|
189
|
-
|
|
197
|
+
...providerReport(analysis),
|
|
198
|
+
}), submitting => { stage = submitting ? "advance" : "analysis"; if (submitting) pauses.delete(contextKey(provider, historyScope)); }, reviewOptions);
|
|
199
|
+
const analysisProvider = analysis.provider || provider;
|
|
200
|
+
const item = history.find(item => item.id === claim.job_id);
|
|
201
|
+
if (item) item.provider = analysisProvider;
|
|
190
202
|
if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
|
|
191
203
|
stage = "adjudication";
|
|
192
|
-
const { response: completed } = await validatedReview(advanced.request,
|
|
204
|
+
const { review: adjudication, response: completed } = await validatedReview(advanced.request, analysisProvider, "invalid_sqm_adjudication_output", adjudication => api.request({
|
|
193
205
|
action: "complete",
|
|
194
206
|
...leaseBody(claim, provider),
|
|
195
207
|
analysis_output: analysis.output,
|
|
196
208
|
adjudication_output: adjudication.output,
|
|
197
209
|
executor_model: adjudication.model || analysis.model,
|
|
198
|
-
|
|
210
|
+
...providerReport(adjudication, analysisProvider),
|
|
211
|
+
...(claim.capabilities?.reported_executor_provider === true ? { analysis_executor_provider: analysisProvider, analysis_executor_model: analysis.model } : {}),
|
|
212
|
+
}), submitting => { stage = submitting ? "complete" : "adjudication"; if (submitting) pauses.delete(contextKey(provider, historyScope)); }, reviewOptions);
|
|
213
|
+
if (item) item.provider = adjudication.provider || analysisProvider;
|
|
199
214
|
await updateTask(claim.job_id, "completed", historyScope);
|
|
200
215
|
return completed;
|
|
201
216
|
} catch (error) {
|
|
@@ -221,7 +236,7 @@ export function createSqmLocalReviewWorker({
|
|
|
221
236
|
return {
|
|
222
237
|
snapshot() {
|
|
223
238
|
const pause = currentPause();
|
|
224
|
-
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
239
|
+
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
225
240
|
...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
|
|
226
241
|
},
|
|
227
242
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
|
|
@@ -229,9 +244,13 @@ export function createSqmLocalReviewWorker({
|
|
|
229
244
|
const item = visibleHistory().find(item => item.key === key);
|
|
230
245
|
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;
|
|
231
246
|
},
|
|
232
|
-
|
|
247
|
+
retry() { if (closed || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
|
|
248
|
+
resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
|
|
233
249
|
poll() {
|
|
234
250
|
if (closed || pollPromise) return pollPromise || Promise.resolve(null);
|
|
251
|
+
const environmentKey = getEnvironmentKey();
|
|
252
|
+
if (pausedEnvironment !== null && pausedEnvironment === environmentKey) return Promise.resolve(null);
|
|
253
|
+
if (pausedEnvironment !== null) { pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; }
|
|
235
254
|
const provider = getProvider();
|
|
236
255
|
if (provider == null) return Promise.resolve(null);
|
|
237
256
|
assertProvider(provider);
|
|
@@ -273,6 +292,9 @@ export function createSqmLocalReviewWorker({
|
|
|
273
292
|
history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
|
|
274
293
|
await persist(scope, item);
|
|
275
294
|
}
|
|
295
|
+
if (["provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable", "provider_sign_in_required", "provider_status_unknown"].includes(error.code)) {
|
|
296
|
+
pausedEnvironment = environmentKey; pauseReason = error.code;
|
|
297
|
+
}
|
|
276
298
|
issueContext = key;
|
|
277
299
|
issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
|
|
278
300
|
onError(error);
|
|
@@ -296,7 +318,33 @@ export function runStructuredCodexReview(request, options) {
|
|
|
296
318
|
return runStructuredProviderReview("codex", request, options);
|
|
297
319
|
}
|
|
298
320
|
|
|
299
|
-
export async function runStructuredProviderReview(
|
|
321
|
+
export async function runStructuredProviderReview(provider, request, options = {}) {
|
|
322
|
+
const fallbackProviders = options.enableEngineFallback === true && options.providerReportingSupported === true && options.readOnlyAnalysis !== false && Array.isArray(options.allowedFallbackProviders)
|
|
323
|
+
? [...new Set(options.allowedFallbackProviders.filter(candidate => ["codex", "claude"].includes(candidate) && candidate !== provider))] : [];
|
|
324
|
+
if (!fallbackProviders.length) return runSingleProviderReview(provider, request, options);
|
|
325
|
+
const deadlineAt = Date.now() + (options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS);
|
|
326
|
+
const attempts = [];
|
|
327
|
+
for (const candidate of [provider, ...fallbackProviders]) {
|
|
328
|
+
if (options.signal?.aborted) throw new SqmLocalReviewError("Local analysis was cancelled.", { code: "analysis_cancelled" });
|
|
329
|
+
if (Date.now() >= deadlineAt) throw new SqmLocalReviewError("Local analysis timed out.", { code: "analysis_timeout" });
|
|
330
|
+
if (candidate !== provider) {
|
|
331
|
+
await options.beforeDispatch?.(candidate);
|
|
332
|
+
const readiness = await (options.inspectProviderStatus || inspectProviderStatus)(candidate);
|
|
333
|
+
if (readiness.state !== "ready") throw new SqmLocalReviewError("Fallback provider is not signed in and ready.", {
|
|
334
|
+
code: readiness.state === "signed_out" ? "provider_sign_in_required" : "provider_status_unknown" });
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const result = await runSingleProviderReview(candidate, request, { ...options, deadlineAt,
|
|
338
|
+
beforeDispatch: () => options.beforeDispatch?.(candidate) });
|
|
339
|
+
return { ...result, provider: candidate, provider_attempts: [...attempts, { provider: candidate, code: "completed" }] };
|
|
340
|
+
} catch (error) {
|
|
341
|
+
attempts.push({ provider: candidate, code: error.code });
|
|
342
|
+
if (!["provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable"].includes(error.code) || candidate === fallbackProviders.at(-1)) throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function runSingleProviderReview(
|
|
300
348
|
provider,
|
|
301
349
|
request,
|
|
302
350
|
{
|
|
@@ -308,6 +356,7 @@ export async function runStructuredProviderReview(
|
|
|
308
356
|
readOnlyAnalysis = true,
|
|
309
357
|
terminationGraceMs = 1_000,
|
|
310
358
|
beforeDispatch = async () => {},
|
|
359
|
+
deadlineAt,
|
|
311
360
|
} = {},
|
|
312
361
|
) {
|
|
313
362
|
assertProvider(provider);
|
|
@@ -330,11 +379,13 @@ export async function runStructuredProviderReview(
|
|
|
330
379
|
"Return only JSON that strictly matches the provided output schema.",
|
|
331
380
|
request.prompt,
|
|
332
381
|
].join("\n\n");
|
|
333
|
-
const deadline = Date.now() + executionTimeoutMs;
|
|
382
|
+
const deadline = deadlineAt ?? Date.now() + executionTimeoutMs;
|
|
334
383
|
const models = provider === "codex" ? SQM_CODEX_MODELS : [null];
|
|
335
|
-
let
|
|
384
|
+
let processResult;
|
|
336
385
|
let selectedModel;
|
|
337
|
-
|
|
386
|
+
let modelIndex = 0, retryAttempt = 0, claudeTurnLimit = 1;
|
|
387
|
+
while (modelIndex < models.length) {
|
|
388
|
+
const model = models[modelIndex];
|
|
338
389
|
await beforeDispatch();
|
|
339
390
|
if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
|
|
340
391
|
const remaining = deadline - Date.now();
|
|
@@ -342,7 +393,7 @@ export async function runStructuredProviderReview(
|
|
|
342
393
|
// Never consume a partial/stale output file left by a rejected model.
|
|
343
394
|
await rm(outputPath, { force: true });
|
|
344
395
|
try {
|
|
345
|
-
|
|
396
|
+
processResult = await spawnReview(
|
|
346
397
|
inspection.executable,
|
|
347
398
|
provider === "claude" ? [
|
|
348
399
|
"--print", "--output-format", "json",
|
|
@@ -350,7 +401,7 @@ export async function runStructuredProviderReview(
|
|
|
350
401
|
"--no-session-persistence", "--tools", "",
|
|
351
402
|
"--setting-sources", "", "--settings", '{"disableAllHooks":true}',
|
|
352
403
|
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
353
|
-
...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns",
|
|
404
|
+
...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", String(claudeTurnLimit)] : []),
|
|
354
405
|
] : [
|
|
355
406
|
"exec",
|
|
356
407
|
"--model",
|
|
@@ -369,27 +420,43 @@ export async function runStructuredProviderReview(
|
|
|
369
420
|
"-",
|
|
370
421
|
],
|
|
371
422
|
prompt,
|
|
372
|
-
{ spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model,
|
|
423
|
+
{ spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
|
|
373
424
|
terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
|
|
374
425
|
);
|
|
375
426
|
selectedModel = model;
|
|
376
427
|
break;
|
|
377
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
|
+
}
|
|
378
438
|
if (error.code !== "provider_model_unsupported" || model === models.at(-1)) throw error;
|
|
439
|
+
modelIndex += 1;
|
|
379
440
|
}
|
|
380
441
|
}
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
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;
|
|
385
455
|
}
|
|
386
|
-
|
|
456
|
+
output = parseProviderJson(text, processResult.diagnostic);
|
|
387
457
|
}
|
|
388
|
-
const output = provider === "claude"
|
|
389
|
-
? envelope?.structured_output
|
|
390
|
-
: JSON.parse(await readFile(outputPath, "utf8"));
|
|
391
458
|
if (!output || Array.isArray(output) || typeof output !== "object") {
|
|
392
|
-
throw
|
|
459
|
+
throw providerFailure("provider_non_object_output", processResult.diagnostic);
|
|
393
460
|
}
|
|
394
461
|
return {
|
|
395
462
|
output,
|
|
@@ -397,7 +464,7 @@ export async function runStructuredProviderReview(
|
|
|
397
464
|
};
|
|
398
465
|
} catch (error) {
|
|
399
466
|
if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
|
|
400
|
-
throw new SqmLocalReviewError(`Local ${label} review failed
|
|
467
|
+
throw new SqmLocalReviewError(`Local ${label} review failed before producing a structured result.`, {
|
|
401
468
|
code: `${provider}_local_review_failed`,
|
|
402
469
|
});
|
|
403
470
|
} finally {
|
|
@@ -418,120 +485,156 @@ function codexAnalysisArguments(system) {
|
|
|
418
485
|
'-c', `developer_instructions=${JSON.stringify(system)}`];
|
|
419
486
|
}
|
|
420
487
|
|
|
421
|
-
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs,
|
|
488
|
+
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt }) {
|
|
422
489
|
return new Promise((resolve, reject) => {
|
|
423
|
-
if (signal?.aborted) return reject(
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
});
|
|
430
|
-
let stdout = "";
|
|
431
|
-
let
|
|
432
|
-
let
|
|
433
|
-
let settled = false;
|
|
434
|
-
let terminalError = null;
|
|
435
|
-
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 });
|
|
436
500
|
const halt = error => {
|
|
437
501
|
if (settled || terminalError) return;
|
|
438
502
|
terminalError = error;
|
|
439
|
-
child.kill?.(
|
|
503
|
+
child.kill?.("SIGTERM");
|
|
440
504
|
if (!settled) killTimer = setTimeout(() => {
|
|
441
|
-
child.kill?.(
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
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);
|
|
445
509
|
}, terminationGraceMs);
|
|
446
510
|
};
|
|
447
|
-
const cancelled = () => halt(
|
|
448
|
-
const timer = setTimeout(() =>
|
|
449
|
-
halt(readOnlyAnalysis ? new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' }) : new Error(`${label} timed out after ${executionTimeoutMs}ms`));
|
|
450
|
-
}, executionTimeoutMs);
|
|
511
|
+
const cancelled = () => halt(providerFailure("analysis_cancelled"));
|
|
512
|
+
const timer = setTimeout(() => halt(providerFailure("analysis_timeout")), executionTimeoutMs);
|
|
451
513
|
timer.unref?.();
|
|
452
514
|
const finish = (error = null) => {
|
|
453
515
|
if (settled) return;
|
|
454
516
|
settled = true;
|
|
455
|
-
clearTimeout(timer);
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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);
|
|
459
543
|
};
|
|
460
544
|
child.stdout?.setEncoding?.("utf8");
|
|
461
|
-
child.stdout?.on?.("data",
|
|
462
|
-
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; }
|
|
463
549
|
stdout += chunk;
|
|
464
|
-
if (readOnlyAnalysis && provider ===
|
|
465
|
-
for (const line of stdout.split(
|
|
550
|
+
if (readOnlyAnalysis && provider === "codex") {
|
|
551
|
+
for (const line of stdout.split("\n").filter(Boolean)) {
|
|
466
552
|
let event;
|
|
467
553
|
try { event = JSON.parse(line); } catch { continue; }
|
|
468
|
-
if ([
|
|
469
|
-
const
|
|
470
|
-
if (
|
|
471
|
-
halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
if (providerModelUnsupported(message, model)) {
|
|
475
|
-
halt(modelUnsupportedError(model));
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
554
|
+
if (["error", "turn.failed"].includes(event.type)) {
|
|
555
|
+
const error = nativeFailure(event.message ?? event.error?.message);
|
|
556
|
+
if (error) { halt(error); return; }
|
|
478
557
|
}
|
|
479
|
-
if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type ||
|
|
480
|
-
halt(
|
|
481
|
-
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;
|
|
482
560
|
}
|
|
483
561
|
}
|
|
484
562
|
}
|
|
485
|
-
if (Buffer.byteLength(stdout, "utf8") > 2 * 1024 * 1024) {
|
|
486
|
-
halt(new Error(`${label} structured output exceeded the size limit`));
|
|
487
|
-
}
|
|
488
563
|
});
|
|
489
|
-
child.stdin?.on?.("error",
|
|
564
|
+
child.stdin?.on?.("error", () => halt(providerFailure("provider_process_failed")));
|
|
490
565
|
child.stderr?.setEncoding?.("utf8");
|
|
491
|
-
child.stderr?.on?.("data",
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
for (const line of lines) checkStderr(line);
|
|
498
|
-
}
|
|
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);
|
|
499
572
|
});
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
const event = JSON.parse(message);
|
|
507
|
-
if (event.type !== "error") return;
|
|
508
|
-
message = event.error?.message ?? event.message;
|
|
509
|
-
} catch { /* Older CLIs print a plain native ERROR line. */ }
|
|
510
|
-
if (providerSignInRequired(message)) {
|
|
511
|
-
halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
|
|
512
|
-
} else if (providerModelUnsupported(message, model)) {
|
|
513
|
-
halt(modelUnsupportedError(model));
|
|
514
|
-
}
|
|
515
|
-
};
|
|
516
|
-
child.on("error", finish);
|
|
517
|
-
child.on("close", (code) => {
|
|
518
|
-
if (provider === "codex") checkStderr(stderrPending);
|
|
519
|
-
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") {
|
|
520
578
|
let envelope;
|
|
521
|
-
try { envelope = JSON.parse(stdout); } catch { /*
|
|
522
|
-
if (envelope
|
|
523
|
-
|
|
524
|
-
|
|
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);
|
|
525
585
|
}
|
|
526
586
|
}
|
|
527
|
-
finish(terminalError || (
|
|
587
|
+
finish(terminalError || (exitCode === 0 ? null : providerFailure("provider_process_failed")));
|
|
528
588
|
});
|
|
529
|
-
signal?.addEventListener(
|
|
589
|
+
signal?.addEventListener("abort", cancelled, { once: true });
|
|
530
590
|
if (signal?.aborted) { cancelled(); return; }
|
|
531
591
|
child.stdin?.end(prompt);
|
|
532
592
|
});
|
|
533
593
|
}
|
|
534
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
|
+
|
|
535
638
|
function providerModelUnsupported(message, model) {
|
|
536
639
|
if (typeof message !== "string" || typeof model !== "string") return false;
|
|
537
640
|
const rejection = message.trim().match(/^The ['"]([^'"]+)['"] model is not supported(?: when using Codex with a ChatGPT account)?\.?$/i);
|
|
@@ -601,7 +704,8 @@ function reviewFailure(error, stage, provider) {
|
|
|
601
704
|
else if (stage === "analysis" || stage === "adjudication") code = `${provider}_local_review_failed`;
|
|
602
705
|
}
|
|
603
706
|
return { stage: /^[a-z_]{1,40}$/.test(error?.stage || "") ? error.stage : stage, code,
|
|
604
|
-
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) };
|
|
605
709
|
}
|
|
606
710
|
|
|
607
711
|
function formatFailure(failure, failureReported = null) {
|
|
@@ -613,9 +717,11 @@ function formatFailure(failure, failureReported = null) {
|
|
|
613
717
|
: failure.status === 403 || failure.code === "desktop_access_required" ? "Check SQM and Developer access for the selected organization."
|
|
614
718
|
: failure.status === 401 ? "Reconnect your SIMY session."
|
|
615
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]
|
|
616
721
|
: failure.code.endsWith("_local_review_failed") ? "The local AI app did not complete a valid structured review."
|
|
617
722
|
: "Open analysis details to inspect the task state.";
|
|
618
|
-
|
|
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." : ""}`;
|
|
619
725
|
}
|
|
620
726
|
|
|
621
727
|
function analysisTaskUrl(workspaceUrl, id) {
|
package/src/sqm/session.js
CHANGED
|
@@ -18,10 +18,18 @@ export async function ensureSqmSession(
|
|
|
18
18
|
startAgentImpl = startAgent,
|
|
19
19
|
openAuthorizationUrlImpl = openAuthorizationUrl,
|
|
20
20
|
createAgentShutdownImpl = createAgentShutdown,
|
|
21
|
+
expectedScope,
|
|
22
|
+
signal,
|
|
23
|
+
rejectedToken,
|
|
21
24
|
} = {},
|
|
22
25
|
) {
|
|
23
26
|
let session = await readSessionImpl(webOrigin);
|
|
24
|
-
|
|
27
|
+
const pinnedScope = expectedScope || sqmSessionScope(session, webOrigin);
|
|
28
|
+
signal?.throwIfAborted();
|
|
29
|
+
if (session?.token !== rejectedToken && sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
|
|
30
|
+
assertSqmSessionScope(session, webOrigin, pinnedScope);
|
|
31
|
+
return session;
|
|
32
|
+
}
|
|
25
33
|
|
|
26
34
|
const agent = await startAgentImpl({ requestedPort: 0, webOrigin, quiet: true });
|
|
27
35
|
const shutdown = createAgentShutdownImpl(agent);
|
|
@@ -35,19 +43,41 @@ export async function ensureSqmSession(
|
|
|
35
43
|
|
|
36
44
|
const deadline = now() + timeoutMs;
|
|
37
45
|
while (now() < deadline) {
|
|
46
|
+
signal?.throwIfAborted();
|
|
38
47
|
await sleep(Math.min(500, Math.max(1, deadline - now())));
|
|
39
48
|
session = await readSessionImpl(webOrigin);
|
|
40
|
-
if (sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
|
|
49
|
+
if (session?.token !== rejectedToken && sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
|
|
50
|
+
assertSqmSessionScope(session, webOrigin, pinnedScope);
|
|
41
51
|
logger.log("SIMY CLI authorization completed. Continuing SQM check.");
|
|
42
52
|
return session;
|
|
43
53
|
}
|
|
44
54
|
}
|
|
45
|
-
|
|
55
|
+
const error = new Error("SIMY authorization timed out. Complete the browser sign-in and rerun the SQM command.");
|
|
56
|
+
error.code = "authorization_timeout";
|
|
57
|
+
throw error;
|
|
46
58
|
} finally {
|
|
47
59
|
await shutdown();
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
export function sqmSessionScope(session, webOrigin) {
|
|
64
|
+
return { account_id: session?.account_id || session?.auth_user_id || null,
|
|
65
|
+
organization_id: session?.organization_id || session?.org_id || null,
|
|
66
|
+
device_id: session?.device_id || null, api_base_url: session?.api_base_url || null,
|
|
67
|
+
web_origin: new URL(webOrigin).origin };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function assertSqmSessionScope(session, webOrigin, expected) {
|
|
71
|
+
const current = sqmSessionScope(session, webOrigin);
|
|
72
|
+
for (const field of ["account_id", "organization_id", "device_id", "api_base_url", "web_origin"]) {
|
|
73
|
+
if (expected?.[field] && expected[field] !== current[field]) {
|
|
74
|
+
const error = new Error(`SQM authorization changed its ${field}; resume the original authorized scope.`);
|
|
75
|
+
error.code = "authorization_scope_changed";
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
51
81
|
function sqmSessionReady(session, webOrigin, now, isSessionValidImpl) {
|
|
52
82
|
return Boolean(
|
|
53
83
|
isSessionValidImpl(session, now, webOrigin) &&
|