claude-spotter 1.9.2 → 1.9.3

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/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  各節はそのversion公開時点の変更記録であり、後続versionにより置換された仕様を含む。
4
4
  現行runtime契約は[`docs/00_overview.md`](https://github.com/kitepon/Spotter/blob/main/docs/00_overview.md)から辿る。
5
5
 
6
+ ## 1.9.3 — 2026-10-07
7
+
8
+ - 監査backendへ届かない・応じてもらえない失敗(network、timeout、認証、利用上限、5xx)を、1回ごとに
9
+ runtime errorへ登録しない。Spotterは固定の通知を出し、親のturnと入力を保ち、次の監査で再試行する。
10
+ 1回の失敗はhook eventとdaemon logに残り、backendごとの連続失敗として`auditor-availability-v1.json`に持つ。
11
+ 完了した監査が挟まらないまま30分以上続いた時だけ、`SPOTTER.AUDITOR.UNRECOVERED`(`high`)を
12
+ 障害1件につき1回登録する。原因は決めつけない。それ以外の監査失敗は従来どおり毎回
13
+ `SPOTTER.AUDITOR.UNAVAILABLE`(`warn`)へ登録する。BugHubへ送る項目は変えていない。
14
+ - 重大度を、止まる範囲と復帰の有無に合わせた。sessionまたはbackendの監査が止まり復帰が観測されない物は
15
+ `high`、影響が監査1回・hookの要求1回までの物は`warn`。入力・会話・結果の喪失と二重実行はどの種類にも無い。
16
+ - hookとの接続1本の障害を、daemonが待ち受けられない失敗(`SPOTTER.DAEMON.TRANSPORT`、`high`)から分け、
17
+ `SPOTTER.DAEMON.CONNECTION`(`warn`)として登録する。daemonは他の接続を処理し続ける。
18
+ - `SPOTTER.AUDITOR.UNRECOVERED`または`SPOTTER.DAEMON.CONNECTION`の記録を持つstoreは、1.9.2以前のSpotterでは
19
+ 読めない(未知の記録として拒否する)。
20
+
6
21
  ## 1.9.2 — 2026-10-06
7
22
 
8
23
  - daemonが起動直後にsessionのcwdを離れ、`~/.spotter`へ移る。hostが`SessionEnd`を実行せずに終わると、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,10 @@ import {
31
31
  hookEventsPath,
32
32
  summarizeHookEvents,
33
33
  } from '../core/hook-event-log.mjs';
34
- import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
34
+ import {
35
+ observeAuditorAvailabilityIsolatedSafe, observeRuntimeErrorIsolatedSafe,
36
+ } from '../core/runtime-error-store.mjs';
37
+ import { reportAuditorFailure, reportAuditorSuccess } from '../core/auditor-outcome.mjs';
35
38
  import { createEvaluationStore } from '../core/evaluation-store.mjs';
36
39
  import { loadEvaluationContext } from '../core/evaluation-context.mjs';
37
40
  import {
@@ -87,11 +90,17 @@ export async function runCodexHookCommand({ argv = process.argv.slice(2) } = {})
87
90
  return;
88
91
  }
89
92
  if (sub === 'user-prompt-submit') {
90
- await runCodexUserPromptSubmitHook({ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe });
93
+ await runCodexUserPromptSubmitHook({
94
+ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
95
+ auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
96
+ });
91
97
  return;
92
98
  }
93
99
  if (sub === 'stop') {
94
- await runCodexStopHook({ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe });
100
+ await runCodexStopHook({
101
+ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
102
+ auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
103
+ });
95
104
  return;
96
105
  }
97
106
  process.stderr.write(`unknown codex-hook subcommand: ${sub}\n${CODEX_HOOK_USAGE}`);
@@ -130,6 +139,7 @@ export async function runCodexUserPromptSubmitHook({
130
139
  writeOutput = (text) => process.stdout.write(text),
131
140
  writeError = (text) => process.stderr.write(text),
132
141
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
142
+ auditorAvailabilityObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
133
143
  createEvaluationStoreFn = createEvaluationStore,
134
144
  loadEvaluationContextFn = loadEvaluationContext,
135
145
  randomUUIDFn = randomUUID,
@@ -206,7 +216,9 @@ export async function runCodexUserPromptSubmitHook({
206
216
  });
207
217
  } catch (err) {
208
218
  await recordEvaluation({ auditStatus: 'error', backend: err?.backend ?? null, model: err?.diagnostics?.modelSelection?.effectiveModel ?? null });
209
- if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
219
+ if (enteredAuditorBoundary) {
220
+ await reportAuditorFailure(err, { runtimeErrorObserver, auditorAvailabilityObserver, backend: backend?.name });
221
+ }
210
222
  const failure = projectBackendFailure(err?.code);
211
223
  safeWriteError(writeError, failure.stderr);
212
224
  await recordCodexHookEventSafe(recordHookEventFn, {
@@ -224,6 +236,7 @@ export async function runCodexUserPromptSubmitHook({
224
236
  writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
225
237
  return;
226
238
  }
239
+ await reportAuditorSuccess(judgment, { auditorAvailabilityObserver, backend: backend.name });
227
240
  await recordCodexHookEventSafe(recordHookEventFn, {
228
241
  projectRoot,
229
242
  event: {
@@ -267,6 +280,7 @@ export async function runCodexStopHook({
267
280
  writeOutput = (text) => process.stdout.write(text),
268
281
  writeError = (text) => process.stderr.write(text),
269
282
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
283
+ auditorAvailabilityObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
270
284
  createEvaluationStoreFn = createEvaluationStore,
271
285
  codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
272
286
  now = () => Date.now(),
@@ -368,7 +382,9 @@ export async function runCodexStopHook({
368
382
  backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
369
383
  judgment = await backend.judge({ stage: 'turn_end', finalResponse, usedTools });
370
384
  } catch (err) {
371
- if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
385
+ if (enteredAuditorBoundary) {
386
+ await reportAuditorFailure(err, { runtimeErrorObserver, auditorAvailabilityObserver, backend: backend?.name });
387
+ }
372
388
  const failure = projectBackendFailure(err?.code);
373
389
  reportError(failure.stderr);
374
390
  writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
@@ -387,6 +403,7 @@ export async function runCodexStopHook({
387
403
  }, reportError);
388
404
  return;
389
405
  }
406
+ await reportAuditorSuccess(judgment, { auditorAvailabilityObserver, backend: backend.name });
390
407
  if (judgment.pass === true) {
391
408
  await recordCodexHookEventSafe(recordHookEventFn, {
392
409
  projectRoot,
@@ -701,14 +718,6 @@ function createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBack
701
718
  });
702
719
  }
703
720
 
704
- async function observeRuntimeFailure(observer, kind) {
705
- try {
706
- await observer(kind);
707
- } catch {
708
- // Runtime error telemetry must not alter hook output or exit behavior.
709
- }
710
- }
711
-
712
721
  function resolveCodexHookAuditorBackend({ env }) {
713
722
  return selectAuditorBackend({ hostAgent: 'codex', env }).backend;
714
723
  }
@@ -5,7 +5,9 @@ import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { open } from 'node:fs/promises';
7
7
  import { writeFileSync } from 'node:fs';
8
- import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
8
+ import {
9
+ observeAuditorAvailabilityIsolatedSafe, observeRuntimeErrorIsolatedSafe,
10
+ } from '../core/runtime-error-store.mjs';
9
11
 
10
12
  function parseArgs(argv) {
11
13
  const out = { sessionId: null, projectRoot: null };
@@ -94,6 +96,7 @@ export async function runDaemonStart({ argv }) {
94
96
  projectRoot,
95
97
  logFn: log,
96
98
  runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
99
+ auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
97
100
  });
98
101
  } catch (err) {
99
102
  if (err instanceof DaemonAlreadyRunningError) {
@@ -0,0 +1,56 @@
1
+ // Decides how one auditor outcome reaches the runtime error store. A single failure to reach
2
+ // or be served by the external backend is a handled, self-retrying condition: Spotter shows the
3
+ // fixed notice, keeps the parent turn and the user's input, and audits again on the next turn.
4
+ // It stays in the hook-event and daemon logs. It is registered only when it does not recover.
5
+
6
+ export const AUDITOR_AVAILABILITY_BACKENDS = new Set(['jev', 'haiku', 'codex-cli', 'unknown']);
7
+
8
+ // The code picks only when to register. It does not say whether the product or the
9
+ // environment is at fault: a timeout or a rejected login can be either.
10
+ const AUDITOR_BACKEND_ACCESS_CODES = new Set([
11
+ 'E_JEV_NETWORK', 'E_JEV_TIMEOUT', 'E_JEV_AUTH', 'E_JEV_USAGE_LIMIT',
12
+ 'E_CODEX_CLI_TIMEOUT', 'E_CODEX_CLI_AUTH', 'E_CODEX_CLI_USAGE_LIMIT',
13
+ 'E_HAIKU_TIMEOUT',
14
+ ]);
15
+
16
+ // 'immediate' keeps the registration on every occurrence. 'on_unrecovered' is registered
17
+ // only when the failure streak outlives the recovery window without a successful audit.
18
+ export function auditorFailureLane(error) {
19
+ const code = error?.code;
20
+ if (AUDITOR_BACKEND_ACCESS_CODES.has(code)) return 'on_unrecovered';
21
+ const status = error?.diagnostics?.status;
22
+ if (code === 'E_JEV_HTTP' && Number.isSafeInteger(status) && status >= 500) return 'on_unrecovered';
23
+ return 'immediate';
24
+ }
25
+
26
+ export function auditorAvailabilityBackend(value) {
27
+ return AUDITOR_AVAILABILITY_BACKENDS.has(value) ? value : 'unknown';
28
+ }
29
+
30
+ export async function reportAuditorFailure(error, {
31
+ runtimeErrorObserver, auditorAvailabilityObserver, backend,
32
+ }) {
33
+ try {
34
+ if (auditorFailureLane(error) === 'immediate') {
35
+ await runtimeErrorObserver('auditor_unavailable');
36
+ return;
37
+ }
38
+ await auditorAvailabilityObserver({
39
+ outcome: 'failure', backend: auditorAvailabilityBackend(error?.backend ?? backend),
40
+ });
41
+ } catch {
42
+ // Runtime error telemetry must not alter hook output, exit behavior or daemon state.
43
+ }
44
+ }
45
+
46
+ // A judgment produced without contacting the backend proves nothing about its availability.
47
+ export async function reportAuditorSuccess(judgment, { auditorAvailabilityObserver, backend }) {
48
+ if (judgment?.meta?.mode === 'empty_catalog') return;
49
+ try {
50
+ await auditorAvailabilityObserver({
51
+ outcome: 'success', backend: auditorAvailabilityBackend(judgment?.meta?.backend ?? backend),
52
+ });
53
+ } catch {
54
+ // Same telemetry safety boundary as reportAuditorFailure.
55
+ }
56
+ }
@@ -1,10 +1,10 @@
1
- import { hasRuntimeErrorReceipt, observeRuntimeError } from './runtime-error-store.mjs';
1
+ import { hasRuntimeErrorReceipt, observeAuditorAvailability, observeRuntimeError } from './runtime-error-store.mjs';
2
2
 
3
3
  async function main() {
4
4
  if (process.env.SPOTTER_RUNTIME_ERROR_WORKER !== '1' || process.argv.length !== 5) process.exit(2);
5
5
  const action = process.argv[2];
6
6
  const value = process.argv[3];
7
- if (action !== 'observe' && action !== 'receipt') process.exit(2);
7
+ if (action !== 'observe' && action !== 'receipt' && action !== 'availability') process.exit(2);
8
8
  let options;
9
9
  try {
10
10
  const encoded = process.argv[4];
@@ -15,8 +15,11 @@ async function main() {
15
15
  }
16
16
  const allowed = action === 'observe'
17
17
  ? new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'beforeOpenDelayMs', 'observationId'])
18
- : new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']);
19
- const required = new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId']);
18
+ : action === 'availability'
19
+ ? new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch'])
20
+ : new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']);
21
+ const required = new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch']);
22
+ if (action !== 'availability') required.add('observationId');
20
23
  if (!options || typeof options !== 'object' || Array.isArray(options)
21
24
  || Object.keys(options).some((key) => !allowed.has(key))
22
25
  || [...required].some((key) => !Object.hasOwn(options, key))
@@ -26,7 +29,7 @@ async function main() {
26
29
  || typeof options.productVersion !== 'string' || options.productVersion.length < 1 || options.productVersion.length > 64
27
30
  || typeof options.platform !== 'string' || !/^[a-z0-9_-]{1,32}$/.test(options.platform)
28
31
  || typeof options.arch !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(options.arch)
29
- || typeof options.observationId !== 'string' || !/^[a-f0-9]{32}$/.test(options.observationId)
32
+ || (action !== 'availability' && (typeof options.observationId !== 'string' || !/^[a-f0-9]{32}$/.test(options.observationId)))
30
33
  || (action === 'receipt' && (typeof options.expectedFingerprint !== 'string'
31
34
  || !/^[a-f0-9]{64}$/.test(options.expectedFingerprint)
32
35
  || !Number.isSafeInteger(options.waitMs) || options.waitMs < 10 || options.waitMs > 10_000))
@@ -42,6 +45,14 @@ async function main() {
42
45
  } while (Date.now() < deadline);
43
46
  process.exit(11);
44
47
  }
48
+ if (action === 'availability') {
49
+ const separator = value.indexOf(':');
50
+ if (separator < 1) process.exit(2);
51
+ const result = await observeAuditorAvailability({
52
+ outcome: value.slice(0, separator), backend: value.slice(separator + 1),
53
+ }, options);
54
+ process.exit(result.collected ? 0 : 10);
55
+ }
45
56
  const result = await observeRuntimeError(value, options);
46
57
  process.exit(result.collected ? 0 : 10);
47
58
  } catch {
@@ -16,33 +16,68 @@ import { DatabaseSync } from 'node:sqlite';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { version } from '../version.mjs';
18
18
  import { WINDOWS_POWERSHELL_COMMAND } from '../platform/spawn.mjs';
19
+ import { AUDITOR_AVAILABILITY_BACKENDS } from './auditor-outcome.mjs';
19
20
  import { readRuntimeReportConfig } from './runtime-report-config.mjs';
20
21
 
21
22
  export const RUNTIME_ERROR_STORE_SCHEMA = 'spotter.runtime_errors.v1';
22
23
  export const RUNTIME_ERROR_STATE_SCHEMA_VERSION = '1.0';
23
24
  export const RUNTIME_ERROR_STORE_FAILURE_DIAGNOSTIC = 'spotter-runtime-errors: local aggregate store unavailable\n';
24
25
 
26
+ // Severity follows what stops, what is lost and whether it comes back. No kind loses the user's
27
+ // input, conversation or results, and none runs anything twice, so none is fatal.
28
+ // - high: audits are stopped across a whole session or backend and no recovery is observed.
29
+ // - warn: at most one audit or one hook request is affected and the next one runs normally.
25
30
  export const RUNTIME_ERROR_DEFINITIONS = deepFreeze({
31
+ // The daemon could not listen, or its listening server failed. A daemon that cannot listen
32
+ // exits: every audit of that Claude session is missing from the first prompt, and each
33
+ // UserPromptSubmit retries the start and waits up to 3 s for it. It comes back only when the
34
+ // cause on the terminal is gone. For an error after listening the daemon keeps running and
35
+ // the extent is not determined here.
26
36
  daemon_transport: {
27
37
  component: 'daemon_transport',
28
38
  errorCode: 'SPOTTER.DAEMON.TRANSPORT',
29
39
  messageTemplate: 'Spotter daemon transport failed',
30
40
  severity: 'high',
31
41
  },
42
+ // A fault on one live hook connection. The daemon keeps serving and the next request opens
43
+ // a new connection. A hook that goes away before the reply does not raise it.
44
+ daemon_connection: {
45
+ component: 'daemon_transport',
46
+ errorCode: 'SPOTTER.DAEMON.CONNECTION',
47
+ messageTemplate: 'Spotter daemon connection to a hook failed; at most one hook request was affected',
48
+ severity: 'warn',
49
+ },
50
+ // The PID file could not be written, so the daemon closes its listener and exits. Same
51
+ // effect on the session as a daemon that cannot listen.
32
52
  daemon_persistence: {
33
53
  component: 'daemon_persistence',
34
54
  errorCode: 'SPOTTER.DAEMON.PERSISTENCE',
35
55
  messageTemplate: 'Spotter daemon state persistence failed',
36
56
  severity: 'high',
37
57
  },
58
+ // One audit failed for a reason other than backend access. The next audit runs normally.
38
59
  auditor_unavailable: {
39
60
  component: 'auditor',
40
61
  errorCode: 'SPOTTER.AUDITOR.UNAVAILABLE',
41
62
  messageTemplate: 'Spotter auditor backend was unavailable',
42
63
  severity: 'warn',
43
64
  },
65
+ // Registered once per outage, only after backend access kept failing with no completed
66
+ // audit in between: every audit through that backend on this terminal has been missing for
67
+ // 30 minutes or more and no recovery is observed.
68
+ auditor_unrecovered: {
69
+ component: 'auditor',
70
+ errorCode: 'SPOTTER.AUDITOR.UNRECOVERED',
71
+ messageTemplate: 'Spotter auditor backend access kept failing for over 30 minutes with no successful audit; cause not determined',
72
+ severity: 'high',
73
+ },
44
74
  });
45
75
 
76
+ export const AUDITOR_AVAILABILITY_SCHEMA = 'spotter.auditor_availability.v1';
77
+ export const AUDITOR_UNRECOVERED_AFTER_MS = 30 * 60 * 1_000;
78
+ const AUDITOR_AVAILABILITY_FILE = 'auditor-availability-v1.json';
79
+ const AUDITOR_AVAILABILITY_OUTCOMES = new Set(['success', 'failure']);
80
+
46
81
  const CONFIG_TOP_KEYS = new Set(['schema_version', 'host', 'collection', 'reporting']);
47
82
  const HOST_KEYS = new Set(['id', 'profile']);
48
83
  const COLLECTION_KEYS = new Set(['enabled']);
@@ -151,56 +186,220 @@ export async function observeRuntimeError(input, options = {}) {
151
186
  const definition = RUNTIME_ERROR_DEFINITIONS[kind];
152
187
  const fingerprint = runtimeErrorFingerprint(definition);
153
188
  const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
154
- const result = await mutateStore(storePath, options, (store) => {
155
- const receipt = observationId
156
- ? store.receipts.find((candidate) => candidate.id === observationId)
157
- : null;
158
- if (receipt) {
159
- if (receipt.fingerprint !== fingerprint) {
160
- throw storeError('runtime error observation id conflicts with another fingerprint');
161
- }
162
- const existing = store.records.find((record) => record.fingerprint === fingerprint);
163
- return { collected: true, fingerprint, sequence: existing?.sequence ?? null, duplicate: true };
189
+ return mutateStore(storePath, options, (store) => (
190
+ applyObservation(store, definition, fingerprint, observationId, options)
191
+ ));
192
+ }
193
+
194
+ function applyObservation(store, definition, fingerprint, observationId, options) {
195
+ const receipt = observationId
196
+ ? store.receipts.find((candidate) => candidate.id === observationId)
197
+ : null;
198
+ if (receipt) {
199
+ if (receipt.fingerprint !== fingerprint) {
200
+ throw storeError('runtime error observation id conflicts with another fingerprint');
164
201
  }
165
- const timestamp = nowIso(options.now);
166
202
  const existing = store.records.find((record) => record.fingerprint === fingerprint);
167
- const sequence = store.next_sequence++;
168
- if (existing) {
169
- existing.product_version = validateProductVersion(options.productVersion ?? version);
170
- existing.occurrence_count += 1;
171
- existing.last_seen = timestamp;
172
- existing.status = 'open';
173
- existing.resolved_at = null;
174
- existing.reason_code = null;
175
- existing.sequence = sequence;
176
- } else {
177
- store.records.push({
178
- product: 'spotter',
179
- product_version: validateProductVersion(options.productVersion ?? version),
180
- component: definition.component,
181
- error_code: definition.errorCode,
182
- message_template: definition.messageTemplate,
183
- severity: definition.severity,
184
- fingerprint,
185
- occurrence_count: 1,
186
- first_seen: timestamp,
187
- last_seen: timestamp,
188
- state_schema_version: RUNTIME_ERROR_STATE_SCHEMA_VERSION,
189
- os: validatePlatform(options.platform ?? currentPlatform()),
190
- arch: validateArch(options.arch ?? currentArch()),
191
- status: 'open',
192
- resolved_at: null,
193
- reason_code: null,
194
- sequence,
195
- });
203
+ return { collected: true, fingerprint, sequence: existing?.sequence ?? null, duplicate: true };
204
+ }
205
+ const timestamp = nowIso(options.now);
206
+ const existing = store.records.find((record) => record.fingerprint === fingerprint);
207
+ const sequence = store.next_sequence++;
208
+ if (existing) {
209
+ existing.product_version = validateProductVersion(options.productVersion ?? version);
210
+ existing.occurrence_count += 1;
211
+ existing.last_seen = timestamp;
212
+ existing.status = 'open';
213
+ existing.resolved_at = null;
214
+ existing.reason_code = null;
215
+ existing.sequence = sequence;
216
+ } else {
217
+ store.records.push({
218
+ product: 'spotter',
219
+ product_version: validateProductVersion(options.productVersion ?? version),
220
+ component: definition.component,
221
+ error_code: definition.errorCode,
222
+ message_template: definition.messageTemplate,
223
+ severity: definition.severity,
224
+ fingerprint,
225
+ occurrence_count: 1,
226
+ first_seen: timestamp,
227
+ last_seen: timestamp,
228
+ state_schema_version: RUNTIME_ERROR_STATE_SCHEMA_VERSION,
229
+ os: validatePlatform(options.platform ?? currentPlatform()),
230
+ arch: validateArch(options.arch ?? currentArch()),
231
+ status: 'open',
232
+ resolved_at: null,
233
+ reason_code: null,
234
+ sequence,
235
+ });
236
+ }
237
+ if (observationId) {
238
+ store.receipts.push({ id: observationId, fingerprint });
239
+ if (store.receipts.length > MAX_RECEIPTS) store.receipts.splice(0, store.receipts.length - MAX_RECEIPTS);
240
+ }
241
+ return { collected: true, fingerprint, sequence };
242
+ }
243
+
244
+ export function auditorAvailabilityPath(storePath) {
245
+ return join(dirname(storePath), AUDITOR_AVAILABILITY_FILE);
246
+ }
247
+
248
+ export async function observeAuditorAvailability(input, options = {}) {
249
+ const { outcome, backend } = validateAvailabilityInput(input);
250
+ const collection = await readRuntimeCollectionMode(options);
251
+ if (!collection.enabled) return { collected: false, reason: collection.mode };
252
+ const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
253
+ const statePath = auditorAvailabilityPath(storePath);
254
+ return enqueue(storePath, async () => {
255
+ await ensurePrivateDirectory(dirname(storePath), options);
256
+ return withStoreLock(runtimeErrorLockPath(storePath), options, async () => {
257
+ const state = await readAvailabilityState(statePath, options);
258
+ const streak = state.streaks[backend];
259
+ if (outcome === 'success') {
260
+ if (!streak) return { collected: true, streak: 'none' };
261
+ delete state.streaks[backend];
262
+ await writeAvailabilityState(statePath, state);
263
+ return { collected: true, streak: 'cleared' };
264
+ }
265
+ const timestamp = nowIso(options.now);
266
+ // A clock that moved backwards restarts the streak instead of producing a negative age.
267
+ const current = streak && streak.first_failed_at <= timestamp
268
+ ? { ...streak, last_failed_at: timestamp }
269
+ : { first_failed_at: timestamp, last_failed_at: timestamp, registered: false };
270
+ let registered = false;
271
+ if (!current.registered
272
+ && Date.parse(timestamp) - Date.parse(current.first_failed_at) >= AUDITOR_UNRECOVERED_AFTER_MS) {
273
+ const definition = RUNTIME_ERROR_DEFINITIONS.auditor_unrecovered;
274
+ const fingerprint = runtimeErrorFingerprint(definition);
275
+ // The streak start identifies the outage, so a retry after a failed state write
276
+ // cannot count the same outage twice.
277
+ const observationId = createHash('sha256')
278
+ .update(`auditor_unrecovered\n${backend}\n${current.first_failed_at}`, 'utf8').digest('hex').slice(0, 32);
279
+ const store = await readStore(storePath, options);
280
+ applyObservation(store, definition, fingerprint, observationId, { ...options, now: () => timestamp });
281
+ validateStore(store);
282
+ const atomicWriteFn = options.atomicWriteFn ?? atomicWriteStore;
283
+ await atomicWriteFn(storePath, `${JSON.stringify(store)}\n`, options);
284
+ current.registered = true;
285
+ registered = true;
286
+ }
287
+ state.streaks[backend] = current;
288
+ await writeAvailabilityState(statePath, state);
289
+ return { collected: true, streak: current.registered ? 'registered' : 'open', registered };
290
+ });
291
+ });
292
+ }
293
+
294
+ // Success is the hot path of every audit. A worker is started only when this backend has a
295
+ // failure streak to end; the unlocked read is a hint and the worker re-reads under the lock.
296
+ export async function observeAuditorAvailabilityIsolatedSafe(input, options = {}) {
297
+ let parsed;
298
+ try {
299
+ parsed = validateAvailabilityInput(input);
300
+ } catch {
301
+ return emitFixedStoreFailure(options);
302
+ }
303
+ const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
304
+ if (parsed.outcome === 'success') {
305
+ let streaks;
306
+ try {
307
+ streaks = JSON.parse(await readFile(auditorAvailabilityPath(storePath), 'utf8'))?.streaks;
308
+ } catch (error) {
309
+ if (error?.code === 'ENOENT') return { collected: false, reason: 'no_failure_streak' };
310
+ return emitFixedStoreFailure(options);
196
311
  }
197
- if (observationId) {
198
- store.receipts.push({ id: observationId, fingerprint });
199
- if (store.receipts.length > MAX_RECEIPTS) store.receipts.splice(0, store.receipts.length - MAX_RECEIPTS);
312
+ if (!streaks || typeof streaks !== 'object') return emitFixedStoreFailure(options);
313
+ if (!Object.hasOwn(streaks, parsed.backend)) return { collected: false, reason: 'no_failure_streak' };
314
+ }
315
+ const platform = options.platform ?? process.platform;
316
+ const timeoutMs = options.timeoutMs ?? (platform === 'win32'
317
+ ? WINDOWS_DEFAULT_ISOLATED_TIMEOUT_MS
318
+ : DEFAULT_ISOLATED_TIMEOUT_MS);
319
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 10_000) {
320
+ return emitFixedStoreFailure(options);
321
+ }
322
+ const workerOptions = {
323
+ configPath: options.configPath ?? null,
324
+ productConfigPath: options.productConfigPath,
325
+ storePath,
326
+ productVersion: options.productVersion ?? version,
327
+ platform: options.platform ?? process.platform,
328
+ arch: options.arch ?? process.arch,
329
+ };
330
+ const encoded = Buffer.from(JSON.stringify(workerOptions), 'utf8').toString('base64url');
331
+ const observed = await runRuntimeWorker(
332
+ options.workerPath ?? RUNTIME_ERROR_WORKER,
333
+ ['availability', `${parsed.outcome}:${parsed.backend}`, encoded],
334
+ timeoutMs,
335
+ );
336
+ if (observed.kind === 'exit' && observed.code === 0) return { collected: true };
337
+ if (observed.kind === 'exit' && observed.code === 10) return { collected: false, reason: 'collection_disabled' };
338
+ return emitFixedStoreFailure(options);
339
+ }
340
+
341
+ function validateAvailabilityInput(input) {
342
+ assertExactObject(input, new Set(['outcome', 'backend']));
343
+ if (!AUDITOR_AVAILABILITY_OUTCOMES.has(input.outcome) || !AUDITOR_AVAILABILITY_BACKENDS.has(input.backend)) {
344
+ throw inputError('invalid auditor availability observation');
345
+ }
346
+ return { outcome: input.outcome, backend: input.backend };
347
+ }
348
+
349
+ async function readAvailabilityState(statePath, options) {
350
+ let raw;
351
+ try {
352
+ raw = decodeUtf8(await readPrivateFile(statePath, options, { enforceWindowsAcl: false }));
353
+ } catch (error) {
354
+ if (error?.code === 'ENOENT') return { schema: AUDITOR_AVAILABILITY_SCHEMA, streaks: {} };
355
+ throw error;
356
+ }
357
+ let state;
358
+ try {
359
+ state = JSON.parse(raw);
360
+ } catch {
361
+ throw storeError('auditor availability state is malformed');
362
+ }
363
+ if (!state || typeof state !== 'object' || Array.isArray(state)
364
+ || !hasOnlyKeys(state, new Set(['schema', 'streaks'])) || Object.keys(state).length !== 2
365
+ || state.schema !== AUDITOR_AVAILABILITY_SCHEMA
366
+ || !state.streaks || typeof state.streaks !== 'object' || Array.isArray(state.streaks)
367
+ || !hasOnlyKeys(state.streaks, AUDITOR_AVAILABILITY_BACKENDS)) {
368
+ throw storeError('auditor availability state schema mismatch');
369
+ }
370
+ const streakKeys = new Set(['first_failed_at', 'last_failed_at', 'registered']);
371
+ for (const streak of Object.values(state.streaks)) {
372
+ if (!streak || typeof streak !== 'object' || Array.isArray(streak)
373
+ || !hasOnlyKeys(streak, streakKeys) || Object.keys(streak).length !== streakKeys.size
374
+ || !validTimestamp(streak.first_failed_at) || !validTimestamp(streak.last_failed_at)
375
+ || streak.first_failed_at > streak.last_failed_at || typeof streak.registered !== 'boolean') {
376
+ throw storeError('auditor availability state schema mismatch');
200
377
  }
201
- return { collected: true, fingerprint, sequence };
202
- });
203
- return result;
378
+ }
379
+ return state;
380
+ }
381
+
382
+ // The directory is already owner-private (0700, or the owner-only inherited ACL on Windows),
383
+ // and the state holds only backend names and timestamps.
384
+ async function writeAvailabilityState(statePath, state) {
385
+ if (Object.keys(state.streaks).length === 0) {
386
+ await rm(statePath, { force: true });
387
+ return;
388
+ }
389
+ const temporary = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
390
+ let handle;
391
+ try {
392
+ handle = await open(temporary, 'wx', 0o600);
393
+ await handle.writeFile(`${JSON.stringify(state)}\n`, 'utf8');
394
+ await handle.sync();
395
+ await handle.close();
396
+ handle = null;
397
+ await rename(temporary, statePath);
398
+ } catch (error) {
399
+ await handle?.close().catch(() => {});
400
+ await rm(temporary, { force: true }).catch(() => {});
401
+ throw error;
402
+ }
204
403
  }
205
404
 
206
405
  export async function observeRuntimeErrorSafe(input, options = {}) {
@@ -35,6 +35,7 @@ import { readFile } from 'node:fs/promises';
35
35
  import { createServer, ensureRuntimeDir, removeStaleSocketFile, secureSocketFile, socketPath } from './transport.mjs';
36
36
  import { readLocal } from '../tool-db/refresh.mjs';
37
37
  import { legacyResultFromJudgment } from '../core/judgment.mjs';
38
+ import { reportAuditorFailure, reportAuditorSuccess } from '../core/auditor-outcome.mjs';
38
39
  import {
39
40
  createAuditorBackend,
40
41
  DEFAULT_HAIKU_AUDITOR_TIMEOUT_MS,
@@ -86,6 +87,7 @@ export async function startDaemon({
86
87
  auditorEnv = process.env,
87
88
  stopShortFinalMaxChars = resolveStopShortFinalMaxChars(process.env),
88
89
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
90
+ auditorAvailabilityObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
89
91
  createAuditorBackendFn = createAuditorBackend,
90
92
  createServerFn = createServer,
91
93
  removeStaleSocketFileFn = removeStaleSocketFile,
@@ -180,14 +182,20 @@ export async function startDaemon({
180
182
  // Tests may pass haikuCallWindowMs: 0 to disable this guard.
181
183
  let lastAuditorCallAt = 0;
182
184
 
185
+ const auditorOutcomeObservers = {
186
+ runtimeErrorObserver, auditorAvailabilityObserver, backend: auditorBackend.name,
187
+ };
183
188
  const runAuditorJudgment = async (input) => {
184
189
  lastAuditorCallAt = Date.now();
190
+ let judgment;
185
191
  try {
186
- return await auditorBackend.judge(input);
192
+ judgment = await auditorBackend.judge(input);
187
193
  } catch (error) {
188
- await observeFailure('auditor_unavailable');
194
+ await reportAuditorFailure(error, auditorOutcomeObservers);
189
195
  throw error;
190
196
  }
197
+ await reportAuditorSuccess(judgment, auditorOutcomeObservers);
198
+ return judgment;
191
199
  };
192
200
 
193
201
  // v0.12.0: heartbeat. Reset on every envelope; if no event arrives within
@@ -405,9 +413,9 @@ export async function startDaemon({
405
413
  if (envelope?.event === 'tool_used' && envelope.payload?.evaluation_observed === true) {
406
414
  state.evaluationUsageIncomplete = true;
407
415
  }
408
- // Handler/auditor failures are owned above. A connection-level error has no
409
- // envelope and belongs to the transport boundary here.
410
- if (envelope === null) void observeFailure('daemon_transport');
416
+ // Handler/auditor failures are owned above. A connection-level error has no envelope.
417
+ // It affects that one connection; the daemon keeps serving the others.
418
+ if (envelope === null) void observeFailure('daemon_connection');
411
419
  };
412
420
 
413
421
  const { server, path } = createServerFn({ sessionId, handler, onError: onErrorFn });
package/src/index.mjs CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  summarizeDaemonLogText,
49
49
  summarizeDaemonLogs,
50
50
  } from './core/daemon-log-diagnostics.mjs';
51
+ export { auditorFailureLane } from './core/auditor-outcome.mjs';
51
52
  export {
52
53
  RUNTIME_ERROR_DEFINITIONS,
53
54
  RUNTIME_ERROR_STORE_SCHEMA,
@@ -55,6 +56,8 @@ export {
55
56
  compactRuntimeErrors,
56
57
  defaultFactoryReporterConfigPath,
57
58
  defaultRuntimeErrorStorePath,
59
+ observeAuditorAvailability,
60
+ observeAuditorAvailabilityIsolatedSafe,
58
61
  observeRuntimeError,
59
62
  observeRuntimeErrorIsolatedSafe,
60
63
  observeRuntimeErrorSafe,