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 +15 -0
- package/package.json +1 -1
- package/src/cli/codex-hook-cmd.mjs +22 -13
- package/src/cli/daemon-cmd.mjs +4 -1
- package/src/core/auditor-outcome.mjs +56 -0
- package/src/core/runtime-error-store-worker.mjs +16 -5
- package/src/core/runtime-error-store.mjs +245 -46
- package/src/daemon/daemon.mjs +13 -5
- package/src/index.mjs +3 -0
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
|
@@ -31,7 +31,10 @@ import {
|
|
|
31
31
|
hookEventsPath,
|
|
32
32
|
summarizeHookEvents,
|
|
33
33
|
} from '../core/hook-event-log.mjs';
|
|
34
|
-
import {
|
|
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({
|
|
93
|
+
await runCodexUserPromptSubmitHook({
|
|
94
|
+
runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
|
|
95
|
+
auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
|
|
96
|
+
});
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
93
99
|
if (sub === 'stop') {
|
|
94
|
-
await runCodexStopHook({
|
|
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)
|
|
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)
|
|
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
|
}
|
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
:
|
|
19
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 (
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 = {}) {
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -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
|
-
|
|
192
|
+
judgment = await auditorBackend.judge(input);
|
|
187
193
|
} catch (error) {
|
|
188
|
-
await
|
|
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
|
-
//
|
|
410
|
-
if (envelope === null) void observeFailure('
|
|
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,
|