instar 1.3.1300 → 1.3.1302
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/dist/commands/server.js +9 -14
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +6 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/AutonomousSessions.d.ts +11 -0
- package/dist/core/AutonomousSessions.d.ts.map +1 -1
- package/dist/core/AutonomousSessions.js +29 -0
- package/dist/core/AutonomousSessions.js.map +1 -1
- package/dist/core/GateSignalDetectors.d.ts +9 -0
- package/dist/core/GateSignalDetectors.d.ts.map +1 -1
- package/dist/core/GateSignalDetectors.js.map +1 -1
- package/dist/core/JevCascade.d.ts +9 -0
- package/dist/core/JevCascade.d.ts.map +1 -1
- package/dist/core/JevCascade.js +12 -3
- package/dist/core/JevCascade.js.map +1 -1
- package/dist/core/JevSignalShadow.d.ts +128 -1
- package/dist/core/JevSignalShadow.d.ts.map +1 -1
- package/dist/core/JevSignalShadow.js +277 -24
- package/dist/core/JevSignalShadow.js.map +1 -1
- package/dist/core/MessagingToneGate.d.ts +34 -3
- package/dist/core/MessagingToneGate.d.ts.map +1 -1
- package/dist/core/MessagingToneGate.js +81 -13
- package/dist/core/MessagingToneGate.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +36 -2
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +9 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +46 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +28 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/dist/feedback-factory/inbox/inboxAwareness.d.ts +12 -0
- package/dist/feedback-factory/inbox/inboxAwareness.d.ts.map +1 -0
- package/dist/feedback-factory/inbox/inboxAwareness.js +18 -0
- package/dist/feedback-factory/inbox/inboxAwareness.js.map +1 -0
- package/dist/feedback-factory/inbox/resolveInboxBlobToken.d.ts +19 -0
- package/dist/feedback-factory/inbox/resolveInboxBlobToken.d.ts.map +1 -0
- package/dist/feedback-factory/inbox/resolveInboxBlobToken.js +40 -0
- package/dist/feedback-factory/inbox/resolveInboxBlobToken.js.map +1 -0
- package/dist/scaffold/templates.d.ts +4 -0
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +14 -2
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/scheduler/JobScheduler.d.ts.map +1 -1
- package/dist/scheduler/JobScheduler.js +12 -3
- package/dist/scheduler/JobScheduler.js.map +1 -1
- package/dist/scheduler/jobDeclaredFailure.d.ts +13 -0
- package/dist/scheduler/jobDeclaredFailure.d.ts.map +1 -0
- package/dist/scheduler/jobDeclaredFailure.js +67 -0
- package/dist/scheduler/jobDeclaredFailure.js.map +1 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +48 -6
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -1
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +12 -1
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +66 -66
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/src/scaffold/templates/jobs/instar/feedback-factory-process.md +13 -5
- package/src/scaffold/templates.ts +15 -2
- package/upgrades/1.3.1301.md +69 -0
- package/upgrades/1.3.1302.md +121 -0
- package/upgrades/side-effects/age-limit-coordinator-r2.md +296 -0
- package/upgrades/side-effects/feedback-inbox-vault-token.md +230 -0
- package/upgrades/side-effects/jev-signal-live.md +173 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type GateSignal } from './GateSignalDetectors.js';
|
|
1
2
|
import { type CascadeBand, type EscalationReason } from './JevCascade.js';
|
|
2
3
|
import type { IntelligenceProvider } from './types.js';
|
|
3
4
|
export declare const JEV_SHADOW_FEATURE = "jev-signal-shadow";
|
|
@@ -34,8 +35,57 @@ export interface JevSignalShadowConfig {
|
|
|
34
35
|
maxPerDay?: number;
|
|
35
36
|
};
|
|
36
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The live advisory switch (docs/specs/jev-signal-live.md). When on, the tone
|
|
40
|
+
* gate awaits Jev (bounded) and uses its CONFIDENT answers for the B1–B7
|
|
41
|
+
* artefact signals; unsure or missing answers fall back to the detectors.
|
|
42
|
+
* `enabled` is resolved through the development-agent gate before it reaches
|
|
43
|
+
* the shadow (live on a dev agent, dark on the fleet unless flipped).
|
|
44
|
+
*/
|
|
45
|
+
export interface JevSignalLiveConfig {
|
|
46
|
+
enabled?: boolean;
|
|
47
|
+
/** Fetch abort for the live call (default LIVE_DEFAULT_TIMEOUT_MS, clamped to LIVE_TIMEOUT_MIN_MS–LIVE_TIMEOUT_MAX_MS). */
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
/** Confident-vs-unsure band (default: the cascade's 0.30–0.70). */
|
|
50
|
+
band?: CascadeBand;
|
|
51
|
+
bands?: Record<string, CascadeBand>;
|
|
52
|
+
}
|
|
53
|
+
/** Covers the measured p99 (834 ms) of Jev calls on real candidates. */
|
|
54
|
+
export declare const LIVE_DEFAULT_TIMEOUT_MS = 1000;
|
|
55
|
+
/** Operator `timeoutMs` is clamped to this range, so no config value can make the gate wait long. */
|
|
56
|
+
export declare const LIVE_TIMEOUT_MIN_MS = 100;
|
|
57
|
+
export declare const LIVE_TIMEOUT_MAX_MS = 3000;
|
|
58
|
+
/** Extra wait past the fetch abort before the caller stops waiting regardless. */
|
|
59
|
+
export declare const LIVE_RACE_SLACK_MS = 250;
|
|
60
|
+
/** An in-flight slot older than this is treated as abandoned and reclaimed. */
|
|
61
|
+
export declare const STALE_SLOT_MS = 30000;
|
|
62
|
+
/** Consecutive live failures (missed deadline, timeout, HTTP error, model mismatch) that open the breaker. */
|
|
63
|
+
export declare const LIVE_BREAKER_FAILURES = 3;
|
|
64
|
+
/** How long an open breaker keeps live mode on the detector path. */
|
|
65
|
+
export declare const LIVE_BREAKER_OPEN_MS: number;
|
|
66
|
+
/**
|
|
67
|
+
* What a rule's line on a live candidate rests on: `jev` = a confident Jev
|
|
68
|
+
* answer decided it; `disputed` = the detector matched and Jev confidently
|
|
69
|
+
* disagrees — the detection stands, annotated with Jev's view; `detector-fallback`
|
|
70
|
+
* = Jev was unsure or had no answer, the detector decided.
|
|
71
|
+
*/
|
|
72
|
+
export type LiveSource = 'jev' | 'disputed' | 'detector-fallback';
|
|
73
|
+
/** What the tone gate receives from a live call. Never rejects. */
|
|
74
|
+
export interface LiveSignalsResult {
|
|
75
|
+
signals: GateSignal[];
|
|
76
|
+
/** True when at least one signal line came from a confident Jev answer —
|
|
77
|
+
* i.e. the list the judge sees differs from the detector list. An agreed
|
|
78
|
+
* "nothing here" does not count, so the prompt stays today's. */
|
|
79
|
+
jevUsed: boolean;
|
|
80
|
+
}
|
|
37
81
|
/** The closed set of reasons a candidate was not compared. */
|
|
38
|
-
export type NotComparedReason = 'timeout' | 'http-error' | 'oversize' | 'skipped-concurrent' | 'skipped-sample' | 'model-mismatch' | 'disabled-no-key' | 'soak-expired'
|
|
82
|
+
export type NotComparedReason = 'timeout' | 'http-error' | 'oversize' | 'skipped-concurrent' | 'skipped-sample' | 'model-mismatch' | 'disabled-no-key' | 'soak-expired'
|
|
83
|
+
/** Live only: the text scrub failed, so nothing was sent. */
|
|
84
|
+
| 'scrub-error'
|
|
85
|
+
/** Live only: the vendor answered but gave no usable probability at all. */
|
|
86
|
+
| 'no-answers'
|
|
87
|
+
/** Live only: the vendor breaker opened (one row per opening). */
|
|
88
|
+
| 'breaker-open';
|
|
39
89
|
export type ShadowRow = {
|
|
40
90
|
kind: 'compared';
|
|
41
91
|
ts: string;
|
|
@@ -46,6 +96,13 @@ export type ShadowRow = {
|
|
|
46
96
|
ms: number;
|
|
47
97
|
modelServed: string;
|
|
48
98
|
disagree: string[];
|
|
99
|
+
/** Present on live candidates (the answers fed the tone gate). */
|
|
100
|
+
live?: true;
|
|
101
|
+
/** Live only: which source decided each rule's signal. */
|
|
102
|
+
liveSources?: Record<string, LiveSource>;
|
|
103
|
+
/** Live only: the answer arrived after the caller stopped waiting, so
|
|
104
|
+
* every rule for this message used the detector. */
|
|
105
|
+
liveLate?: true;
|
|
49
106
|
/** Scrubbed, span-anchored excerpt — present ONLY on disagreeing rows,
|
|
50
107
|
* and only when retention is enabled. */
|
|
51
108
|
excerpt?: string;
|
|
@@ -59,6 +116,8 @@ export type ShadowRow = {
|
|
|
59
116
|
sha256: string;
|
|
60
117
|
bytes: number;
|
|
61
118
|
reason: NotComparedReason;
|
|
119
|
+
/** Live candidate: every rule for this message fell back to the detector. */
|
|
120
|
+
live?: true;
|
|
62
121
|
} | {
|
|
63
122
|
kind: 'referee';
|
|
64
123
|
ts: string;
|
|
@@ -98,6 +157,8 @@ export declare const KEY_REREAD_MS: number;
|
|
|
98
157
|
export interface JevSignalShadowDeps {
|
|
99
158
|
/** Live config read — called per candidate so the kill-switch needs no restart. */
|
|
100
159
|
getConfig: () => JevSignalShadowConfig | undefined;
|
|
160
|
+
/** Live advisory config, `enabled` already dev-gate-resolved. Absent ⇒ never live. */
|
|
161
|
+
getLiveConfig?: () => JevSignalLiveConfig | undefined;
|
|
101
162
|
/**
|
|
102
163
|
* Resolves the vault key. May block (a keychain lookup), so it is called once
|
|
103
164
|
* at construction — boot, never the message path — and afterwards at most once
|
|
@@ -120,6 +181,13 @@ export interface JevSignalShadowDeps {
|
|
|
120
181
|
}
|
|
121
182
|
export declare class JevSignalShadow {
|
|
122
183
|
private inFlight;
|
|
184
|
+
/** When the current in-flight call started (stale-slot reclaim). */
|
|
185
|
+
private inFlightSince;
|
|
186
|
+
/** Slot generation: only the call that holds the CURRENT slot may release it. */
|
|
187
|
+
private slotGen;
|
|
188
|
+
/** Vendor-down breaker for live mode: consecutive failures, and when it closes again. */
|
|
189
|
+
private liveFailures;
|
|
190
|
+
private liveBreakerUntil;
|
|
123
191
|
private cachedKey;
|
|
124
192
|
/** When the key was last read; re-reads are rate-limited from here. */
|
|
125
193
|
private keyReadAt;
|
|
@@ -127,6 +195,34 @@ export declare class JevSignalShadow {
|
|
|
127
195
|
private readonly deps;
|
|
128
196
|
constructor(deps: JevSignalShadowDeps);
|
|
129
197
|
private refreshKey;
|
|
198
|
+
/**
|
|
199
|
+
* The single-flight slot. A slot held past STALE_SLOT_MS belongs to a call
|
|
200
|
+
* that never settled (a fetch that ignored its abort); it is reclaimed so one
|
|
201
|
+
* stuck request cannot turn every later candidate into a fallback forever.
|
|
202
|
+
* Outstanding requests stay bounded: at most one per STALE_SLOT_MS.
|
|
203
|
+
*/
|
|
204
|
+
private slotBusy;
|
|
205
|
+
/**
|
|
206
|
+
* Take the slot and return its release. The release is generation-checked: a
|
|
207
|
+
* reclaimed (stale) call that settles later cannot free the slot a newer call
|
|
208
|
+
* now holds, so single-flight survives a reclaim.
|
|
209
|
+
*/
|
|
210
|
+
private takeSlot;
|
|
211
|
+
/**
|
|
212
|
+
* Live entry point (docs/specs/jev-signal-live.md). Returns null — without
|
|
213
|
+
* doing anything — when live mode is off, so the caller falls back to
|
|
214
|
+
* `observe()` and today's detector-only path. Otherwise returns a promise that
|
|
215
|
+
* never rejects and settles within timeoutMs + LIVE_RACE_SLACK_MS with the
|
|
216
|
+
* merged signals; any failure resolves to the plain detector signals.
|
|
217
|
+
* The live call IS the shadow's measurement call: it writes the same rows
|
|
218
|
+
* (tagged `live: true`) and feeds the same referee cascade.
|
|
219
|
+
*/
|
|
220
|
+
liveSignals(text: string): Promise<LiveSignalsResult> | null;
|
|
221
|
+
private liveInner;
|
|
222
|
+
/** Breaker bookkeeping per live candidate, as the caller saw it: an answer in
|
|
223
|
+
* time resets the count; a missed deadline, HTTP error, timeout or model
|
|
224
|
+
* mismatch counts as one failure. */
|
|
225
|
+
private noteLiveOutcome;
|
|
130
226
|
/**
|
|
131
227
|
* Fire-and-forget entry point. Synchronous, never throws, never awaited by
|
|
132
228
|
* the caller. The only synchronous work is a config read, a hash, and (when
|
|
@@ -166,6 +262,27 @@ export declare class JevSignalShadow {
|
|
|
166
262
|
private maybeRefer;
|
|
167
263
|
private write;
|
|
168
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Merge Jev's answers into the detector signals (docs/specs/jev-signal-live.md).
|
|
267
|
+
* Per kind:
|
|
268
|
+
* - Jev confident YES (p > band.hi): detected. The detector's signal is kept
|
|
269
|
+
* (its spans + sample still anchor the judge); if the detector missed it, a
|
|
270
|
+
* sample-less model signal is added.
|
|
271
|
+
* - Jev confident NO (p < band.lo) against a detector match: the detection
|
|
272
|
+
* STANDS (detected:true, so every B1–B7 rule can still fire on it) and is
|
|
273
|
+
* annotated with Jev's disagreement for the judge to weigh. Jev never hides
|
|
274
|
+
* or overrides an observation; it can only add to or comment on the list.
|
|
275
|
+
* - Unsure or no answer: the detector's output, unchanged.
|
|
276
|
+
* Pure. Output order follows GATE_SIGNAL_KINDS.
|
|
277
|
+
*/
|
|
278
|
+
export declare function mergeLiveSignals(detected: GateSignal[], jev: Record<string, number>, opts?: {
|
|
279
|
+
band?: CascadeBand;
|
|
280
|
+
bands?: Record<string, CascadeBand>;
|
|
281
|
+
}): {
|
|
282
|
+
signals: GateSignal[];
|
|
283
|
+
sources: Record<string, LiveSource>;
|
|
284
|
+
jevUsed: boolean;
|
|
285
|
+
};
|
|
169
286
|
/**
|
|
170
287
|
* The production wiring, shared by server.ts and the E2E test so the test
|
|
171
288
|
* exercises the same config resolution, key lookup and log location the server
|
|
@@ -179,5 +296,15 @@ export declare function buildJevSignalShadow(opts: {
|
|
|
179
296
|
metrics?: JevSignalShadowDeps['metrics'];
|
|
180
297
|
fetchImpl?: typeof fetch;
|
|
181
298
|
referee?: IntelligenceProvider | null;
|
|
299
|
+
/** Boot-time `intelligence.jevSignalLive`, the fallback when the live read has none. */
|
|
300
|
+
bootLiveBlock?: JevSignalLiveConfig;
|
|
301
|
+
/** The agent's `developmentAgent` flag — decides live mode when `enabled` is omitted. */
|
|
302
|
+
developmentAgent?: boolean;
|
|
303
|
+
/**
|
|
304
|
+
* Is decision-quality recording live? The outbound route hardens a migration
|
|
305
|
+
* advisory into a non-overridable hold when it is not, so live mode is off
|
|
306
|
+
* then. Default: the real recorder's answer.
|
|
307
|
+
*/
|
|
308
|
+
recordingLive?: () => boolean;
|
|
182
309
|
}): JevSignalShadow;
|
|
183
310
|
//# sourceMappingURL=JevSignalShadow.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"JevSignalShadow.d.ts","sourceRoot":"","sources":["../../src/core/JevSignalShadow.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"JevSignalShadow.d.ts","sourceRoot":"","sources":["../../src/core/JevSignalShadow.ts"],"names":[],"mappings":"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"}
|
|
@@ -27,15 +27,30 @@
|
|
|
27
27
|
import fs from 'node:fs';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import crypto from 'node:crypto';
|
|
30
|
-
import { detectGateSignals } from './GateSignalDetectors.js';
|
|
30
|
+
import { detectGateSignals, GATE_SIGNAL_KINDS } from './GateSignalDetectors.js';
|
|
31
31
|
import { scrubForStore } from './durableSecretScrub.js';
|
|
32
|
-
import { selectEscalations, askReferee } from './JevCascade.js';
|
|
32
|
+
import { selectEscalations, askReferee, bandFor } from './JevCascade.js';
|
|
33
|
+
import { resolveDevAgentGate } from './devAgentGate.js';
|
|
34
|
+
import { decisionQualityRecordingLive } from './DecisionQualityRecorderImpl.js';
|
|
33
35
|
export const JEV_SHADOW_FEATURE = 'jev-signal-shadow';
|
|
34
36
|
/** Attribution label for the referee's LLM calls (feature metrics + routing). */
|
|
35
37
|
export const JEV_REFEREE_COMPONENT = 'JevLunaReferee';
|
|
36
38
|
/** Default volume bound on referee calls per UTC day. */
|
|
37
39
|
export const REFEREE_DAILY_CAP = 300;
|
|
38
40
|
const ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
41
|
+
/** Covers the measured p99 (834 ms) of Jev calls on real candidates. */
|
|
42
|
+
export const LIVE_DEFAULT_TIMEOUT_MS = 1000;
|
|
43
|
+
/** Operator `timeoutMs` is clamped to this range, so no config value can make the gate wait long. */
|
|
44
|
+
export const LIVE_TIMEOUT_MIN_MS = 100;
|
|
45
|
+
export const LIVE_TIMEOUT_MAX_MS = 3000;
|
|
46
|
+
/** Extra wait past the fetch abort before the caller stops waiting regardless. */
|
|
47
|
+
export const LIVE_RACE_SLACK_MS = 250;
|
|
48
|
+
/** An in-flight slot older than this is treated as abandoned and reclaimed. */
|
|
49
|
+
export const STALE_SLOT_MS = 30_000;
|
|
50
|
+
/** Consecutive live failures (missed deadline, timeout, HTTP error, model mismatch) that open the breaker. */
|
|
51
|
+
export const LIVE_BREAKER_FAILURES = 3;
|
|
52
|
+
/** How long an open breaker keeps live mode on the detector path. */
|
|
53
|
+
export const LIVE_BREAKER_OPEN_MS = 5 * 60_000;
|
|
39
54
|
/**
|
|
40
55
|
* The frozen comparison contract: one Noul question per rule, the detector
|
|
41
56
|
* signal kind it is compared against, and the positive threshold. Changing a
|
|
@@ -62,6 +77,13 @@ export const EXCERPT_DAILY_CAP = 50;
|
|
|
62
77
|
export const KEY_REREAD_MS = 10 * 60_000;
|
|
63
78
|
export class JevSignalShadow {
|
|
64
79
|
inFlight = false;
|
|
80
|
+
/** When the current in-flight call started (stale-slot reclaim). */
|
|
81
|
+
inFlightSince = 0;
|
|
82
|
+
/** Slot generation: only the call that holds the CURRENT slot may release it. */
|
|
83
|
+
slotGen = 0;
|
|
84
|
+
/** Vendor-down breaker for live mode: consecutive failures, and when it closes again. */
|
|
85
|
+
liveFailures = 0;
|
|
86
|
+
liveBreakerUntil = 0;
|
|
65
87
|
cachedKey = null;
|
|
66
88
|
/** When the key was last read; re-reads are rate-limited from here. */
|
|
67
89
|
keyReadAt = 0;
|
|
@@ -81,6 +103,133 @@ export class JevSignalShadow {
|
|
|
81
103
|
this.cachedKey = null;
|
|
82
104
|
}
|
|
83
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The single-flight slot. A slot held past STALE_SLOT_MS belongs to a call
|
|
108
|
+
* that never settled (a fetch that ignored its abort); it is reclaimed so one
|
|
109
|
+
* stuck request cannot turn every later candidate into a fallback forever.
|
|
110
|
+
* Outstanding requests stay bounded: at most one per STALE_SLOT_MS.
|
|
111
|
+
*/
|
|
112
|
+
slotBusy(nowMs) {
|
|
113
|
+
return this.inFlight && nowMs - this.inFlightSince < STALE_SLOT_MS;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Take the slot and return its release. The release is generation-checked: a
|
|
117
|
+
* reclaimed (stale) call that settles later cannot free the slot a newer call
|
|
118
|
+
* now holds, so single-flight survives a reclaim.
|
|
119
|
+
*/
|
|
120
|
+
takeSlot(nowMs) {
|
|
121
|
+
const gen = ++this.slotGen;
|
|
122
|
+
this.inFlight = true;
|
|
123
|
+
this.inFlightSince = nowMs;
|
|
124
|
+
return () => { if (this.slotGen === gen)
|
|
125
|
+
this.inFlight = false; };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Live entry point (docs/specs/jev-signal-live.md). Returns null — without
|
|
129
|
+
* doing anything — when live mode is off, so the caller falls back to
|
|
130
|
+
* `observe()` and today's detector-only path. Otherwise returns a promise that
|
|
131
|
+
* never rejects and settles within timeoutMs + LIVE_RACE_SLACK_MS with the
|
|
132
|
+
* merged signals; any failure resolves to the plain detector signals.
|
|
133
|
+
* The live call IS the shadow's measurement call: it writes the same rows
|
|
134
|
+
* (tagged `live: true`) and feeds the same referee cascade.
|
|
135
|
+
*/
|
|
136
|
+
liveSignals(text) {
|
|
137
|
+
let live;
|
|
138
|
+
try {
|
|
139
|
+
live = this.deps.getLiveConfig?.();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// @silent-fallback-ok — an unreadable live config means live is off; the gate keeps today's detector-only path.
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (!live || live.enabled !== true)
|
|
146
|
+
return null;
|
|
147
|
+
try {
|
|
148
|
+
return this.liveInner(text, live);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// @silent-fallback-ok — any live-path fault resolves to the detector signals (today's behaviour).
|
|
152
|
+
return Promise.resolve(detectorOnly(text));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
liveInner(text, live) {
|
|
156
|
+
const cfg = this.deps.getConfig() ?? {};
|
|
157
|
+
const nowMs = (this.deps.now ?? Date.now)();
|
|
158
|
+
const ts = new Date(nowMs).toISOString();
|
|
159
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
160
|
+
const maxBytes = this.deps.maxScanBytes ?? 1_000_000;
|
|
161
|
+
const sha256 = crypto.createHash('sha256').update(bytes > maxBytes ? text.slice(0, maxBytes) : text, 'utf8').digest('hex');
|
|
162
|
+
const fallback = (reason) => {
|
|
163
|
+
this.write({ kind: 'not-compared', ts, sha256, bytes, reason, live: true });
|
|
164
|
+
return Promise.resolve(detectorOnly(text));
|
|
165
|
+
};
|
|
166
|
+
if (!this.cachedKey && nowMs - this.keyReadAt >= KEY_REREAD_MS)
|
|
167
|
+
this.refreshKey();
|
|
168
|
+
if (!this.cachedKey) {
|
|
169
|
+
// A standing condition: one row per process, not one per message.
|
|
170
|
+
if (this.bootNoted.has('disabled-no-key'))
|
|
171
|
+
return Promise.resolve(detectorOnly(text));
|
|
172
|
+
this.bootNoted.add('disabled-no-key');
|
|
173
|
+
return fallback('disabled-no-key');
|
|
174
|
+
}
|
|
175
|
+
// During a vendor outage every message would otherwise wait the full
|
|
176
|
+
// timeout; an open breaker keeps them on the detector path (one row when it
|
|
177
|
+
// opens, not one per message).
|
|
178
|
+
if (nowMs < this.liveBreakerUntil)
|
|
179
|
+
return Promise.resolve(detectorOnly(text));
|
|
180
|
+
if (bytes > maxBytes)
|
|
181
|
+
return fallback('oversize');
|
|
182
|
+
if (this.slotBusy(nowMs))
|
|
183
|
+
return fallback('skipped-concurrent');
|
|
184
|
+
// Live mode is not soak-bounded, so the text is secret-scrubbed before it
|
|
185
|
+
// leaves the machine (the detectors and the row hash still see the original).
|
|
186
|
+
const scrubbed = scrubForStore(text);
|
|
187
|
+
if (scrubbed.error || scrubbed.truncated)
|
|
188
|
+
return fallback('scrub-error');
|
|
189
|
+
const signals = detectGateSignals(text);
|
|
190
|
+
const timeoutMs = typeof live.timeoutMs === 'number' && Number.isFinite(live.timeoutMs)
|
|
191
|
+
? Math.max(LIVE_TIMEOUT_MIN_MS, Math.min(LIVE_TIMEOUT_MAX_MS, live.timeoutMs))
|
|
192
|
+
: LIVE_DEFAULT_TIMEOUT_MS;
|
|
193
|
+
const race = { late: false };
|
|
194
|
+
const release = this.takeSlot(nowMs);
|
|
195
|
+
const call = this.dispatch(text, cfg, { ts, sha256, bytes, detectorSignals: signals.map((x) => x.kind), signals }, { live, timeoutMs, race, sendText: scrubbed.text });
|
|
196
|
+
this.lastDispatch = call.then(() => undefined, () => undefined).finally(release);
|
|
197
|
+
let timer;
|
|
198
|
+
const deadline = new Promise((resolve) => {
|
|
199
|
+
timer = setTimeout(() => {
|
|
200
|
+
race.late = true;
|
|
201
|
+
// The caller stops waiting now: record that outcome now, so a call that
|
|
202
|
+
// never settles still leaves a row (a late answer adds a liveLate row).
|
|
203
|
+
this.write({ kind: 'not-compared', ts, sha256, bytes, reason: 'timeout', live: true });
|
|
204
|
+
resolve(null);
|
|
205
|
+
}, timeoutMs + LIVE_RACE_SLACK_MS);
|
|
206
|
+
timer.unref?.();
|
|
207
|
+
});
|
|
208
|
+
// The breaker counts what the CALLER saw, once: a missed deadline is a
|
|
209
|
+
// failure even if the answer lands later, and a late success resets nothing.
|
|
210
|
+
return Promise.race([call.catch(() => null), deadline])
|
|
211
|
+
.then((merged) => {
|
|
212
|
+
this.noteLiveOutcome(merged !== null, ts, sha256, bytes);
|
|
213
|
+
return merged ?? { signals, jevUsed: false };
|
|
214
|
+
})
|
|
215
|
+
.finally(() => { if (timer)
|
|
216
|
+
clearTimeout(timer); });
|
|
217
|
+
}
|
|
218
|
+
/** Breaker bookkeeping per live candidate, as the caller saw it: an answer in
|
|
219
|
+
* time resets the count; a missed deadline, HTTP error, timeout or model
|
|
220
|
+
* mismatch counts as one failure. */
|
|
221
|
+
noteLiveOutcome(ok, ts, sha256, bytes) {
|
|
222
|
+
if (ok) {
|
|
223
|
+
this.liveFailures = 0;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.liveFailures++;
|
|
227
|
+
if (this.liveFailures < LIVE_BREAKER_FAILURES)
|
|
228
|
+
return;
|
|
229
|
+
this.liveFailures = 0;
|
|
230
|
+
this.liveBreakerUntil = (this.deps.now ?? Date.now)() + LIVE_BREAKER_OPEN_MS;
|
|
231
|
+
this.write({ kind: 'not-compared', ts, sha256, bytes, reason: 'breaker-open', live: true });
|
|
232
|
+
}
|
|
84
233
|
/**
|
|
85
234
|
* Fire-and-forget entry point. Synchronous, never throws, never awaited by
|
|
86
235
|
* the caller. The only synchronous work is a config read, a hash, and (when
|
|
@@ -134,7 +283,7 @@ export class JevSignalShadow {
|
|
|
134
283
|
notCompared('oversize');
|
|
135
284
|
return;
|
|
136
285
|
}
|
|
137
|
-
if (this.
|
|
286
|
+
if (this.slotBusy(nowMs)) {
|
|
138
287
|
notCompared('skipped-concurrent');
|
|
139
288
|
return;
|
|
140
289
|
}
|
|
@@ -143,19 +292,31 @@ export class JevSignalShadow {
|
|
|
143
292
|
// beyond what the detector actually pointed at.
|
|
144
293
|
const signals = detectGateSignals(text);
|
|
145
294
|
const detectorSignals = signals.map((s) => s.kind);
|
|
146
|
-
|
|
295
|
+
const release = this.takeSlot(nowMs);
|
|
147
296
|
this.lastDispatch = this.dispatch(text, cfg, { ts, sha256, bytes, detectorSignals, signals })
|
|
297
|
+
.then(() => undefined)
|
|
148
298
|
.catch(() => { })
|
|
149
|
-
.finally(
|
|
299
|
+
.finally(release);
|
|
150
300
|
}
|
|
151
|
-
async dispatch(text, cfg, base
|
|
301
|
+
async dispatch(text, cfg, base,
|
|
302
|
+
/** Live mode: `sendText` is the scrubbed text that leaves the machine; `text`
|
|
303
|
+
* stays the original for excerpt anchoring (spans index the original). */
|
|
304
|
+
live) {
|
|
152
305
|
const model = cfg.model || 'jev-1.13.0';
|
|
153
|
-
const timeoutMs = typeof cfg.timeoutMs === 'number' && cfg.timeoutMs > 0 ? cfg.timeoutMs : 1500;
|
|
306
|
+
const timeoutMs = live ? live.timeoutMs : typeof cfg.timeoutMs === 'number' && cfg.timeoutMs > 0 ? cfg.timeoutMs : 1500;
|
|
307
|
+
const liveTag = live ? { live: true } : {};
|
|
154
308
|
const questions = Object.fromEntries(SHADOW_QUESTIONS.map((q) => [q.rule, { type: 'noul', instructions: q.instructions }]));
|
|
155
309
|
const ctrl = new AbortController();
|
|
156
310
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
157
311
|
const t0 = (this.deps.now ?? Date.now)();
|
|
158
|
-
const nc = (reason) =>
|
|
312
|
+
const nc = (reason) => {
|
|
313
|
+
// A live candidate whose caller deadline already passed has its timeout
|
|
314
|
+
// row; a failure landing after that is the same candidate, not a new row.
|
|
315
|
+
if (live?.race.late)
|
|
316
|
+
return null;
|
|
317
|
+
this.write({ kind: 'not-compared', ts: base.ts, sha256: base.sha256, bytes: base.bytes, reason, ...liveTag });
|
|
318
|
+
return null;
|
|
319
|
+
};
|
|
159
320
|
let tokensIn;
|
|
160
321
|
let outcome = 'error';
|
|
161
322
|
let modelServed;
|
|
@@ -163,7 +324,7 @@ export class JevSignalShadow {
|
|
|
163
324
|
const res = await (this.deps.fetchImpl ?? fetch)(ENDPOINT, {
|
|
164
325
|
method: 'POST',
|
|
165
326
|
headers: { Authorization: `Bearer ${this.cachedKey}`, 'Content-Type': 'application/json' },
|
|
166
|
-
body: JSON.stringify({ state: text, model, questions }),
|
|
327
|
+
body: JSON.stringify({ state: live ? live.sendText : text, model, questions }),
|
|
167
328
|
signal: ctrl.signal,
|
|
168
329
|
});
|
|
169
330
|
if (!res.ok) {
|
|
@@ -171,21 +332,18 @@ export class JevSignalShadow {
|
|
|
171
332
|
this.cachedKey = null;
|
|
172
333
|
this.keyReadAt = (this.deps.now ?? Date.now)();
|
|
173
334
|
} // re-read after KEY_REREAD_MS, never per message
|
|
174
|
-
nc('http-error');
|
|
175
|
-
return;
|
|
335
|
+
return nc('http-error');
|
|
176
336
|
}
|
|
177
337
|
const json = (await res.json());
|
|
178
338
|
modelServed = typeof json.model === 'string' ? json.model : undefined;
|
|
179
339
|
tokensIn = json.usage?.input_tokens;
|
|
180
|
-
if (modelServed !== model)
|
|
181
|
-
nc('model-mismatch');
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
340
|
+
if (modelServed !== model)
|
|
341
|
+
return nc('model-mismatch');
|
|
184
342
|
const jev = {};
|
|
185
343
|
const disagree = [];
|
|
186
344
|
for (const q of SHADOW_QUESTIONS) {
|
|
187
345
|
const p = json.answers?.[q.rule]?.noul;
|
|
188
|
-
if (typeof p !== 'number')
|
|
346
|
+
if (typeof p !== 'number' || !Number.isFinite(p))
|
|
189
347
|
continue;
|
|
190
348
|
jev[q.rule] = p;
|
|
191
349
|
const jevSays = p > POSITIVE_THRESHOLD;
|
|
@@ -193,16 +351,36 @@ export class JevSignalShadow {
|
|
|
193
351
|
if (jevSays !== detectorSays)
|
|
194
352
|
disagree.push(q.rule);
|
|
195
353
|
}
|
|
354
|
+
// A response with no usable probability is a failure, not an answer: it
|
|
355
|
+
// must count toward the breaker instead of resetting it.
|
|
356
|
+
if (live && Object.keys(jev).length === 0)
|
|
357
|
+
return nc('no-answers');
|
|
196
358
|
outcome = disagree.length ? 'fired' : 'noop';
|
|
197
359
|
const { signals, ...rowBase } = base;
|
|
198
|
-
|
|
360
|
+
// Measurement EXTRAS (retained excerpts, the referee) belong to the shadow:
|
|
361
|
+
// on a live call they run only while the shadow itself is measuring (on,
|
|
362
|
+
// inside its soak), so live mode — which is not soak-bound — never carries
|
|
363
|
+
// them past the window. One check, so every extra is bound the same way.
|
|
364
|
+
const measuring = !live || shadowMeasuring(cfg, (this.deps.now ?? Date.now)());
|
|
365
|
+
const retained = measuring && disagree.length > 0 && cfg.retainDisagreementExcerpts === true
|
|
199
366
|
? this.buildExcerpt(text, signals, disagree, cfg, base.ts)
|
|
200
367
|
: {};
|
|
201
|
-
|
|
202
|
-
|
|
368
|
+
// Live: merge, and record which source decided each rule. A late answer
|
|
369
|
+
// (the caller already stopped waiting) is recorded as such, so a row never
|
|
370
|
+
// claims Jev supplied a signal the gate did not actually use.
|
|
371
|
+
const merged = live ? mergeLiveSignals(signals, jev, live.live) : null;
|
|
372
|
+
const liveFields = !live ? {}
|
|
373
|
+
: live.race.late ? { live: true, liveLate: true }
|
|
374
|
+
: { live: true, liveSources: merged.sources };
|
|
375
|
+
this.write({ kind: 'compared', ...rowBase, jev, ms: (this.deps.now ?? Date.now)() - t0, modelServed, disagree, ...liveFields, ...retained });
|
|
376
|
+
if (measuring)
|
|
377
|
+
this.maybeRefer(text, cfg, base.ts, base.sha256, jev, base.detectorSignals);
|
|
378
|
+
return merged ? { signals: merged.signals, jevUsed: merged.jevUsed } : null;
|
|
203
379
|
}
|
|
204
380
|
catch (err) {
|
|
205
|
-
|
|
381
|
+
// Everything after the fetch is guarded (merge is pure, excerpt and referee
|
|
382
|
+
// hand-off catch their own errors), so a throw here is the transport.
|
|
383
|
+
return nc(err?.name === 'AbortError' ? 'timeout' : 'http-error');
|
|
206
384
|
}
|
|
207
385
|
finally {
|
|
208
386
|
clearTimeout(timer);
|
|
@@ -365,17 +543,92 @@ export class JevSignalShadow {
|
|
|
365
543
|
}
|
|
366
544
|
}
|
|
367
545
|
}
|
|
546
|
+
/** True while the shadow itself is measuring: enabled and inside its soak window. */
|
|
547
|
+
function shadowMeasuring(cfg, nowMs) {
|
|
548
|
+
const endsAt = cfg.soakEndsAt ? Date.parse(cfg.soakEndsAt) : NaN;
|
|
549
|
+
return cfg.enabled === true && Number.isFinite(endsAt) && nowMs < endsAt;
|
|
550
|
+
}
|
|
551
|
+
/** The plain detector path, for every live fallback. Never throws. */
|
|
552
|
+
function detectorOnly(text) {
|
|
553
|
+
try {
|
|
554
|
+
return { signals: detectGateSignals(text), jevUsed: false };
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// @silent-fallback-ok — detectGateSignals already contains its own throws; this only guards the impossible.
|
|
558
|
+
return { signals: [], jevUsed: false };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Merge Jev's answers into the detector signals (docs/specs/jev-signal-live.md).
|
|
563
|
+
* Per kind:
|
|
564
|
+
* - Jev confident YES (p > band.hi): detected. The detector's signal is kept
|
|
565
|
+
* (its spans + sample still anchor the judge); if the detector missed it, a
|
|
566
|
+
* sample-less model signal is added.
|
|
567
|
+
* - Jev confident NO (p < band.lo) against a detector match: the detection
|
|
568
|
+
* STANDS (detected:true, so every B1–B7 rule can still fire on it) and is
|
|
569
|
+
* annotated with Jev's disagreement for the judge to weigh. Jev never hides
|
|
570
|
+
* or overrides an observation; it can only add to or comment on the list.
|
|
571
|
+
* - Unsure or no answer: the detector's output, unchanged.
|
|
572
|
+
* Pure. Output order follows GATE_SIGNAL_KINDS.
|
|
573
|
+
*/
|
|
574
|
+
export function mergeLiveSignals(detected, jev, opts = {}) {
|
|
575
|
+
const byKind = new Map();
|
|
576
|
+
for (const sig of detected)
|
|
577
|
+
if (sig.detected)
|
|
578
|
+
byKind.set(sig.kind, sig);
|
|
579
|
+
const signals = [];
|
|
580
|
+
const sources = {};
|
|
581
|
+
for (const kind of GATE_SIGNAL_KINDS) {
|
|
582
|
+
const rule = SHADOW_QUESTIONS.find((q) => q.signalKind === kind)?.rule;
|
|
583
|
+
const det = byKind.get(kind);
|
|
584
|
+
const p = rule ? jev[rule] : undefined;
|
|
585
|
+
const band = rule ? bandFor(rule, opts) : undefined;
|
|
586
|
+
const confident = typeof p === 'number' && Number.isFinite(p) && !!band && (p < band.lo || p > band.hi);
|
|
587
|
+
if (!rule || !confident) {
|
|
588
|
+
if (rule)
|
|
589
|
+
sources[rule] = 'detector-fallback';
|
|
590
|
+
if (det)
|
|
591
|
+
signals.push(det.source ? det : { ...det, source: 'detector' });
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const prob = Math.max(0, Math.min(1, p));
|
|
595
|
+
if (prob > band.hi) {
|
|
596
|
+
sources[rule] = 'jev';
|
|
597
|
+
signals.push(det ? { ...det, source: 'jev', modelProbability: prob } : { kind, detected: true, source: 'jev', modelProbability: prob });
|
|
598
|
+
}
|
|
599
|
+
else if (det) {
|
|
600
|
+
sources[rule] = 'disputed';
|
|
601
|
+
signals.push({ ...det, source: 'detector', modelProbability: prob });
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
sources[rule] = 'jev';
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Nothing Jev-sourced reached the list (all unsure, or only agreed "nothing
|
|
608
|
+
// here") ⇒ hand back the detector list untouched, so the prompt and its
|
|
609
|
+
// promptId stay exactly today's.
|
|
610
|
+
const jevUsed = signals.some((sig) => sig.modelProbability !== undefined);
|
|
611
|
+
return jevUsed ? { signals, sources, jevUsed } : { signals: detected, sources, jevUsed };
|
|
612
|
+
}
|
|
368
613
|
/**
|
|
369
614
|
* The production wiring, shared by server.ts and the E2E test so the test
|
|
370
615
|
* exercises the same config resolution, key lookup and log location the server
|
|
371
616
|
* uses. The live `intelligence` block wins; the boot config is the fallback.
|
|
372
617
|
*/
|
|
373
618
|
export function buildJevSignalShadow(opts) {
|
|
619
|
+
const liveBlock = (key) => {
|
|
620
|
+
const intel = opts.readLiveIntelligence();
|
|
621
|
+
const block = intel && typeof intel === 'object' ? intel[key] : undefined;
|
|
622
|
+
return block && typeof block === 'object' ? block : undefined;
|
|
623
|
+
};
|
|
374
624
|
return new JevSignalShadow({
|
|
375
|
-
getConfig: () =>
|
|
376
|
-
|
|
377
|
-
const block =
|
|
378
|
-
|
|
625
|
+
getConfig: () => (liveBlock('jevSignalShadow') ?? opts.bootBlock),
|
|
626
|
+
getLiveConfig: () => {
|
|
627
|
+
const block = (liveBlock('jevSignalLive') ?? opts.bootLiveBlock ?? {});
|
|
628
|
+
// Dev-gated: an omitted `enabled` is live on a development agent, dark on the fleet.
|
|
629
|
+
// And never while decision-quality recording is off (the route would harden the advisory).
|
|
630
|
+
const gated = resolveDevAgentGate(typeof block.enabled === 'boolean' ? block.enabled : undefined, { developmentAgent: opts.developmentAgent });
|
|
631
|
+
return { ...block, enabled: gated && (opts.recordingLive ?? decisionQualityRecordingLive)() };
|
|
379
632
|
},
|
|
380
633
|
readKey: () => {
|
|
381
634
|
const v = opts.readSecret('typesafe_api_key');
|