@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.
Files changed (42) hide show
  1. package/README.md +99 -6
  2. package/package.json +1 -1
  3. package/src/agent.js +123 -28
  4. package/src/auto-update.js +15 -3
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
  7. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
  8. package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
  9. package/src/codex-plugins/simy/hooks/hooks.json +2 -2
  10. package/src/codex-work-pattern-sync.js +37 -9
  11. package/src/delivery-hooks/command.js +9 -2
  12. package/src/delivery-hooks/desktop.js +10 -12
  13. package/src/delivery-hooks/evidence.js +50 -0
  14. package/src/delivery-hooks/supervisor.js +8 -2
  15. package/src/desktop-access.js +4 -3
  16. package/src/desktop-host.js +669 -73
  17. package/src/doctor.js +2 -1
  18. package/src/host-exit-diagnostics.js +28 -0
  19. package/src/index.js +1 -0
  20. package/src/native-development-report-completion.js +143 -0
  21. package/src/native-development-reports.js +137 -0
  22. package/src/orchestrator/checklist-continuation.js +5 -0
  23. package/src/orchestrator/delivery/WORKFLOW.md +4 -0
  24. package/src/orchestrator/delivery/references/state-model.md +187 -0
  25. package/src/orchestrator/loop.js +119 -34
  26. package/src/provider-links.js +74 -0
  27. package/src/runner.js +31 -2
  28. package/src/session-supervision/automatic-continuation.js +31 -2
  29. package/src/session-supervision/claude-sessions.js +25 -15
  30. package/src/session-supervision/codex-app-server.js +27 -4
  31. package/src/session-supervision/index.js +163 -42
  32. package/src/session-supervision/statistics.js +76 -15
  33. package/src/sqm/command.js +58 -7
  34. package/src/sqm/evidence-client.js +11 -1
  35. package/src/sqm/failure-circuit.js +28 -6
  36. package/src/sqm/history-store.js +49 -18
  37. package/src/sqm/provider-diagnostics.js +1 -0
  38. package/src/sqm/review-worker.js +70 -21
  39. package/src/sqm/workflow-update.js +12 -3
  40. package/src/windows-bootstrap.js +2 -1
  41. package/src/windows-desktop-updater.js +6 -1
  42. package/src/windows-inbound.js +9 -0
@@ -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 uploadRunEvidence({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
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 console.log(`Cloud evidence: local only (${result.upload.reason})`);
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
- return { uploaded: true, ...payload };
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) { return (await load(scope)).failures < SQM_FAILURE_PAUSE_THRESHOLD; },
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: (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD,
101
- paused_at_version: (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD ? state.version : null,
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
  };
@@ -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
- const LIMIT = 100;
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 = (await Promise.all(names.filter(name => /^[a-f0-9]{64}\.json$/.test(name)).map(async name => {
32
- try {
33
- const text = await readFile(join(dir, name), "utf8");
34
- if (text.length > 16_384) return null;
35
- return metadata(JSON.parse(text));
36
- } catch { return null; }
37
- }))).filter(Boolean).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
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 item = (await this.items(scope)).find(item => item.key === key);
68
- if (!item) return null;
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, LIMIT)
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 ? "uploaded" : "kept locally"}`].join("\n")
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;
@@ -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 = 240_000;
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
- if (!["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;
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
- pauses.set(key, { code: failure.code, stage: failure.stage, status: failure.status, failures, recoveryKey,
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, reviewOptions);
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 || error.status !== 422 || error.code !== code || !error.validationFeedback) throw error;
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, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
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) return Promise.resolve(null);
274
- if (pausedEnvironment !== null) { pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; }
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; }