claude-spotter 1.9.2 → 1.9.4
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 +26 -0
- package/package.json +1 -1
- package/src/cli/codex-hook-cmd.mjs +26 -13
- package/src/cli/daemon-cmd.mjs +4 -1
- package/src/cli/grok-hook-cmd.mjs +2 -2
- package/src/core/auditor-outcome.mjs +56 -0
- package/src/core/hook-event-log.mjs +21 -1
- 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/src/platform/spawn.mjs +43 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
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.4 — 2026-10-09
|
|
7
|
+
|
|
8
|
+
- Windowsで、監査の制限時間と同時にCodexが自分で終わった時、`taskkill`がrootを見つけられず(exit 128)、
|
|
9
|
+
Spotterが「process treeの終了を確認できない」(`E_CODEX_CLI_TERMINATION`)として扱っていた。この失敗は
|
|
10
|
+
汎用の失敗として`SPOTTER.AUDITOR.UNAVAILABLE`へ即時登録される。child自身のclose(終了と全stdio pipeのEOF)を
|
|
11
|
+
確認できた時だけ終了済みとし、監査はtimeoutのまま扱う。closeを確認できない時と、128以外の`taskkill`失敗は
|
|
12
|
+
従来どおり`E_CODEX_CLI_TERMINATION`にする。
|
|
13
|
+
- CodexとGrokの失敗hook eventへ、失敗した段の内部code(`internalCode`)と、あればその原因のcode・終了status
|
|
14
|
+
(`causeCode`、`causeExitCode`)を残す。これまでは表示用にまとめた`code`だけが残り、1回の失敗の種類を
|
|
15
|
+
logから読み戻せなかった。記録するのは固定の識別子と終了statusだけで、hookの出力とstderrは変えていない。
|
|
16
|
+
|
|
17
|
+
## 1.9.3 — 2026-10-07
|
|
18
|
+
|
|
19
|
+
- 監査backendへ届かない・応じてもらえない失敗(network、timeout、認証、利用上限、5xx)を、1回ごとに
|
|
20
|
+
runtime errorへ登録しない。Spotterは固定の通知を出し、親のturnと入力を保ち、次の監査で再試行する。
|
|
21
|
+
1回の失敗はhook eventとdaemon logに残り、backendごとの連続失敗として`auditor-availability-v1.json`に持つ。
|
|
22
|
+
完了した監査が挟まらないまま30分以上続いた時だけ、`SPOTTER.AUDITOR.UNRECOVERED`(`high`)を
|
|
23
|
+
障害1件につき1回登録する。原因は決めつけない。それ以外の監査失敗は従来どおり毎回
|
|
24
|
+
`SPOTTER.AUDITOR.UNAVAILABLE`(`warn`)へ登録する。BugHubへ送る項目は変えていない。
|
|
25
|
+
- 重大度を、止まる範囲と復帰の有無に合わせた。sessionまたはbackendの監査が止まり復帰が観測されない物は
|
|
26
|
+
`high`、影響が監査1回・hookの要求1回までの物は`warn`。入力・会話・結果の喪失と二重実行はどの種類にも無い。
|
|
27
|
+
- hookとの接続1本の障害を、daemonが待ち受けられない失敗(`SPOTTER.DAEMON.TRANSPORT`、`high`)から分け、
|
|
28
|
+
`SPOTTER.DAEMON.CONNECTION`(`warn`)として登録する。daemonは他の接続を処理し続ける。
|
|
29
|
+
- `SPOTTER.AUDITOR.UNRECOVERED`または`SPOTTER.DAEMON.CONNECTION`の記録を持つstoreは、1.9.2以前のSpotterでは
|
|
30
|
+
読めない(未知の記録として拒否する)。
|
|
31
|
+
|
|
6
32
|
## 1.9.2 — 2026-10-06
|
|
7
33
|
|
|
8
34
|
- daemonが起動直後にsessionのcwdを離れ、`~/.spotter`へ移る。hostが`SessionEnd`を実行せずに終わると、
|
package/package.json
CHANGED
|
@@ -28,10 +28,14 @@ import {
|
|
|
28
28
|
} from '../hooks/parent-output-projector.mjs';
|
|
29
29
|
import {
|
|
30
30
|
appendHookEvent,
|
|
31
|
+
failureEventFields,
|
|
31
32
|
hookEventsPath,
|
|
32
33
|
summarizeHookEvents,
|
|
33
34
|
} from '../core/hook-event-log.mjs';
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
observeAuditorAvailabilityIsolatedSafe, observeRuntimeErrorIsolatedSafe,
|
|
37
|
+
} from '../core/runtime-error-store.mjs';
|
|
38
|
+
import { reportAuditorFailure, reportAuditorSuccess } from '../core/auditor-outcome.mjs';
|
|
35
39
|
import { createEvaluationStore } from '../core/evaluation-store.mjs';
|
|
36
40
|
import { loadEvaluationContext } from '../core/evaluation-context.mjs';
|
|
37
41
|
import {
|
|
@@ -87,11 +91,17 @@ export async function runCodexHookCommand({ argv = process.argv.slice(2) } = {})
|
|
|
87
91
|
return;
|
|
88
92
|
}
|
|
89
93
|
if (sub === 'user-prompt-submit') {
|
|
90
|
-
await runCodexUserPromptSubmitHook({
|
|
94
|
+
await runCodexUserPromptSubmitHook({
|
|
95
|
+
runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
|
|
96
|
+
auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
|
|
97
|
+
});
|
|
91
98
|
return;
|
|
92
99
|
}
|
|
93
100
|
if (sub === 'stop') {
|
|
94
|
-
await runCodexStopHook({
|
|
101
|
+
await runCodexStopHook({
|
|
102
|
+
runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
|
|
103
|
+
auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
|
|
104
|
+
});
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
97
107
|
process.stderr.write(`unknown codex-hook subcommand: ${sub}\n${CODEX_HOOK_USAGE}`);
|
|
@@ -130,6 +140,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
130
140
|
writeOutput = (text) => process.stdout.write(text),
|
|
131
141
|
writeError = (text) => process.stderr.write(text),
|
|
132
142
|
runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
|
|
143
|
+
auditorAvailabilityObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
|
|
133
144
|
createEvaluationStoreFn = createEvaluationStore,
|
|
134
145
|
loadEvaluationContextFn = loadEvaluationContext,
|
|
135
146
|
randomUUIDFn = randomUUID,
|
|
@@ -206,7 +217,9 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
206
217
|
});
|
|
207
218
|
} catch (err) {
|
|
208
219
|
await recordEvaluation({ auditStatus: 'error', backend: err?.backend ?? null, model: err?.diagnostics?.modelSelection?.effectiveModel ?? null });
|
|
209
|
-
if (enteredAuditorBoundary)
|
|
220
|
+
if (enteredAuditorBoundary) {
|
|
221
|
+
await reportAuditorFailure(err, { runtimeErrorObserver, auditorAvailabilityObserver, backend: backend?.name });
|
|
222
|
+
}
|
|
210
223
|
const failure = projectBackendFailure(err?.code);
|
|
211
224
|
safeWriteError(writeError, failure.stderr);
|
|
212
225
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
@@ -216,6 +229,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
216
229
|
status: 'error',
|
|
217
230
|
backend: err?.backend ?? null,
|
|
218
231
|
code: failure.code,
|
|
232
|
+
...failureEventFields(err),
|
|
219
233
|
...compactCodexModelSelectionForEvent(err?.diagnostics?.modelSelection),
|
|
220
234
|
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
221
235
|
durationMs: Date.now() - startedAt,
|
|
@@ -224,6 +238,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
224
238
|
writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
|
|
225
239
|
return;
|
|
226
240
|
}
|
|
241
|
+
await reportAuditorSuccess(judgment, { auditorAvailabilityObserver, backend: backend.name });
|
|
227
242
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
228
243
|
projectRoot,
|
|
229
244
|
event: {
|
|
@@ -267,6 +282,7 @@ export async function runCodexStopHook({
|
|
|
267
282
|
writeOutput = (text) => process.stdout.write(text),
|
|
268
283
|
writeError = (text) => process.stderr.write(text),
|
|
269
284
|
runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
|
|
285
|
+
auditorAvailabilityObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
|
|
270
286
|
createEvaluationStoreFn = createEvaluationStore,
|
|
271
287
|
codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
|
|
272
288
|
now = () => Date.now(),
|
|
@@ -325,6 +341,7 @@ export async function runCodexStopHook({
|
|
|
325
341
|
hook: 'Stop',
|
|
326
342
|
status: 'error',
|
|
327
343
|
code: failure.code,
|
|
344
|
+
...failureEventFields(err),
|
|
328
345
|
reason: 'tool_usage_observation',
|
|
329
346
|
usedToolCount: 0,
|
|
330
347
|
durationMs: Date.now() - startedAt,
|
|
@@ -368,7 +385,9 @@ export async function runCodexStopHook({
|
|
|
368
385
|
backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
|
|
369
386
|
judgment = await backend.judge({ stage: 'turn_end', finalResponse, usedTools });
|
|
370
387
|
} catch (err) {
|
|
371
|
-
if (enteredAuditorBoundary)
|
|
388
|
+
if (enteredAuditorBoundary) {
|
|
389
|
+
await reportAuditorFailure(err, { runtimeErrorObserver, auditorAvailabilityObserver, backend: backend?.name });
|
|
390
|
+
}
|
|
372
391
|
const failure = projectBackendFailure(err?.code);
|
|
373
392
|
reportError(failure.stderr);
|
|
374
393
|
writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
|
|
@@ -379,6 +398,7 @@ export async function runCodexStopHook({
|
|
|
379
398
|
status: 'error',
|
|
380
399
|
backend: err?.backend ?? null,
|
|
381
400
|
code: failure.code,
|
|
401
|
+
...failureEventFields(err),
|
|
382
402
|
...compactCodexModelSelectionForEvent(err?.diagnostics?.modelSelection),
|
|
383
403
|
usedToolCount: usedTools.length,
|
|
384
404
|
...toolUsageEvent,
|
|
@@ -387,6 +407,7 @@ export async function runCodexStopHook({
|
|
|
387
407
|
}, reportError);
|
|
388
408
|
return;
|
|
389
409
|
}
|
|
410
|
+
await reportAuditorSuccess(judgment, { auditorAvailabilityObserver, backend: backend.name });
|
|
390
411
|
if (judgment.pass === true) {
|
|
391
412
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
392
413
|
projectRoot,
|
|
@@ -701,14 +722,6 @@ function createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBack
|
|
|
701
722
|
});
|
|
702
723
|
}
|
|
703
724
|
|
|
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
725
|
function resolveCodexHookAuditorBackend({ env }) {
|
|
713
726
|
return selectAuditorBackend({ hostAgent: 'codex', env }).backend;
|
|
714
727
|
}
|
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) {
|
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
7
|
import { createAuditorBackend } from '../core/auditor-backend.mjs';
|
|
8
8
|
import { createEvaluationStore } from '../core/evaluation-store.mjs';
|
|
9
|
-
import { appendHookEventSafe } from '../core/hook-event-log.mjs';
|
|
9
|
+
import { appendHookEventSafe, failureEventFields } from '../core/hook-event-log.mjs';
|
|
10
10
|
import { projectBackendFailure, projectToolIds } from '../hooks/parent-output-projector.mjs';
|
|
11
11
|
import { findSpotterMarker, isChildCall, readStdinJson, requireString } from '../hooks/lib.mjs';
|
|
12
12
|
import { readLocal, refresh } from '../tool-db/refresh.mjs';
|
|
@@ -154,7 +154,7 @@ export async function runGrokHook({
|
|
|
154
154
|
const failure = projectBackendFailure(error?.code);
|
|
155
155
|
writeError(failure.stderr);
|
|
156
156
|
await record(projectRoot, expected, {
|
|
157
|
-
status: 'degraded', code: failure.code, durationMs: Date.now() - startedAt,
|
|
157
|
+
status: 'degraded', code: failure.code, ...failureEventFields(error), durationMs: Date.now() - startedAt,
|
|
158
158
|
}, recordFn);
|
|
159
159
|
if (prompt !== null) await recordGrokEvaluation({ createEvaluationStoreFn, projectRoot, input, prompt, status: 'error', writeError });
|
|
160
160
|
return;
|
|
@@ -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
|
+
}
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
// backendDurationMs?: number | null,
|
|
25
25
|
// usedToolCount?: number,
|
|
26
26
|
// legacyPendingDiagnostic?: string | null,
|
|
27
|
-
// toolName?: string | null
|
|
27
|
+
// toolName?: string | null,
|
|
28
|
+
// internalCode?: string | null, // failing step's own code; `code` is the projected one
|
|
29
|
+
// causeCode?: string,
|
|
30
|
+
// causeExitCode?: number
|
|
28
31
|
// }
|
|
29
32
|
|
|
30
33
|
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
@@ -34,6 +37,23 @@ const HOOK_EVENTS_FILE = 'hook-events.jsonl';
|
|
|
34
37
|
export const HOOK_EVENT_SCHEMA = 'spotter.hook_event.v1';
|
|
35
38
|
export const HOOK_EVENTS_SUMMARY_SCHEMA = 'spotter.hook_events_summary.v1';
|
|
36
39
|
|
|
40
|
+
const EVENT_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
41
|
+
|
|
42
|
+
function eventCode(value) {
|
|
43
|
+
return typeof value === 'string' && EVENT_CODE_PATTERN.test(value) ? value : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `code` holds the projected, user-facing code, which folds many failures into one generic value.
|
|
47
|
+
// These fields keep the failing step's own code so a single failure can be read back from the log.
|
|
48
|
+
// Only fixed identifiers and an exit status are taken; messages and provider output never are.
|
|
49
|
+
export function failureEventFields(error) {
|
|
50
|
+
const fields = { internalCode: eventCode(error?.code) };
|
|
51
|
+
const causeCode = eventCode(error?.cause?.code);
|
|
52
|
+
if (causeCode !== null) fields.causeCode = causeCode;
|
|
53
|
+
if (Number.isInteger(error?.cause?.exitCode)) fields.causeExitCode = error.cause.exitCode;
|
|
54
|
+
return fields;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
export function hookEventsPath(projectRoot) {
|
|
38
58
|
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
39
59
|
throw new TypeError('hookEventsPath: projectRoot must be a non-empty string');
|
|
@@ -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,
|
package/src/platform/spawn.mjs
CHANGED
|
@@ -134,12 +134,41 @@ export function npmShimEntryPath(source, shimDir) {
|
|
|
134
134
|
return win32.join(shimDir, ...segments);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// taskkill は root の pid が存在しない時に 128 で終わる。この時 taskkill は何も終了していない。
|
|
138
|
+
const TASKKILL_PROCESS_NOT_FOUND = 128;
|
|
139
|
+
|
|
140
|
+
// 'close' は process の終了と全 stdio pipe の EOF を意味する。root より長生きした子孫が
|
|
141
|
+
// pipe を持っている間は stream が閉じないので、ここは false になる。
|
|
142
|
+
function childHasClosed(child) {
|
|
143
|
+
if (!Number.isInteger(child.exitCode) && typeof child.signalCode !== 'string') return false;
|
|
144
|
+
return [child.stdin, child.stdout, child.stderr].every((stream) => !stream || stream.destroyed === true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function waitForChildClose(child, graceMs) {
|
|
148
|
+
if (childHasClosed(child)) return Promise.resolve(true);
|
|
149
|
+
if (typeof child.once !== 'function' || typeof child.off !== 'function') return Promise.resolve(false);
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const onClose = () => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
resolve(true);
|
|
154
|
+
};
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
child.off('close', onClose);
|
|
157
|
+
resolve(childHasClosed(child));
|
|
158
|
+
}, graceMs);
|
|
159
|
+
child.once('close', onClose);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
137
163
|
// cmd.exe shim のみ kill すると孫の CLI が残り得るため、Windows は process tree を
|
|
138
164
|
// taskkill で終了する。taskkill 自体の起動・終了に失敗した時だけ direct kill へ戻す。
|
|
165
|
+
// taskkill が root を見つけられなかった時は、child 自身の close を確認できた場合だけ終了済みとする。
|
|
166
|
+
// timeout と同時に自分で終わった process を「終了未確認」にしないためで、close が無ければ従来どおり失敗する。
|
|
139
167
|
export function terminateProcessTree(child, {
|
|
140
168
|
platform = process.platform,
|
|
141
169
|
spawnFn = spawn,
|
|
142
170
|
timeoutMs = 5_000,
|
|
171
|
+
closeGraceMs = 500,
|
|
143
172
|
} = {}) {
|
|
144
173
|
if (!child || typeof child.kill !== 'function') return Promise.resolve();
|
|
145
174
|
if (platform !== 'win32' || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
|
|
@@ -184,9 +213,20 @@ export function terminateProcessTree(child, {
|
|
|
184
213
|
finish();
|
|
185
214
|
return;
|
|
186
215
|
}
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
216
|
+
const fail = () => {
|
|
217
|
+
const error = new Error(`taskkill exited with code ${code}`);
|
|
218
|
+
error.code = 'E_PROCESS_TREE_TERMINATION';
|
|
219
|
+
error.exitCode = code;
|
|
220
|
+
finish(error);
|
|
221
|
+
};
|
|
222
|
+
if (code !== TASKKILL_PROCESS_NOT_FOUND) {
|
|
223
|
+
fail();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
waitForChildClose(child, closeGraceMs).then((closed) => {
|
|
227
|
+
if (closed) finish();
|
|
228
|
+
else fail();
|
|
229
|
+
});
|
|
190
230
|
});
|
|
191
231
|
} catch (cause) {
|
|
192
232
|
const error = new Error(`taskkill spawn failed: ${cause.message}`);
|