@reticlehq/server 2.5.0 → 2.6.0
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/SKILL.md +27 -10
- package/dist/bridge/bridge.d.ts +12 -0
- package/dist/bridge/bridge.js +32 -2
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +71 -3
- package/dist/cli/cli-parse-feedback.d.ts +31 -0
- package/dist/cli/cli-parse-feedback.js +56 -0
- package/dist/cli/cli-parse.d.ts +5 -9
- package/dist/cli/cli-parse.js +7 -23
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +35 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +131 -11
- package/dist/daemon/daemon-resilience.d.ts +24 -0
- package/dist/daemon/daemon-resilience.js +105 -17
- package/dist/daemon/daemon.js +26 -1
- package/dist/daemon/heartbeat.d.ts +115 -0
- package/dist/daemon/heartbeat.js +141 -0
- package/dist/daemon/idle-shutdown.d.ts +13 -5
- package/dist/daemon/idle-shutdown.js +14 -6
- package/dist/daemon/port-presence.d.ts +76 -0
- package/dist/daemon/port-presence.js +93 -0
- package/dist/daemon/state-dir.d.ts +7 -0
- package/dist/daemon/state-dir.js +59 -0
- package/dist/daemon/unclean-predecessor.d.ts +23 -0
- package/dist/daemon/unclean-predecessor.js +27 -0
- package/dist/events/predicate-eval.d.ts +10 -0
- package/dist/events/predicate-eval.js +24 -4
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +54 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.js +1 -16
- package/dist/flows/flow-replay.d.ts +1 -1
- package/dist/flows/flow-replay.js +19 -2
- package/dist/flows/flow-step-runners.js +2 -3
- package/dist/flows/flow-tools.js +10 -14
- package/dist/flows/role-drift-reason.d.ts +20 -0
- package/dist/flows/role-drift-reason.js +39 -0
- package/dist/flows/suite-flakes.d.ts +33 -0
- package/dist/flows/suite-flakes.js +32 -0
- package/dist/honesty/blind-spots.d.ts +19 -0
- package/dist/honesty/blind-spots.js +33 -0
- package/dist/honesty/verified.d.ts +10 -1
- package/dist/honesty/verified.js +16 -0
- package/dist/http-server.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +10 -1
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +22 -4
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +6 -0
- package/dist/init/detect.js +8 -0
- package/dist/init/mcp-clients.d.ts +81 -0
- package/dist/init/mcp-clients.js +248 -0
- package/dist/init/node-io.js +8 -2
- package/dist/init/plan-framework.js +36 -2
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +73 -9
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +133 -16
- package/dist/init/windows-quote.d.ts +38 -0
- package/dist/init/windows-quote.js +70 -0
- package/dist/mcp/mcp-outage.d.ts +1 -0
- package/dist/mcp/mcp-outage.js +8 -1
- package/dist/mcp/mcp-proxy.d.ts +11 -1
- package/dist/mcp/mcp-proxy.js +30 -7
- package/dist/mcp/mcp.js +29 -2
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/no-session-diagnosis.d.ts +7 -0
- package/dist/session/no-session-diagnosis.js +53 -3
- package/dist/session/no-session-watch.d.ts +3 -1
- package/dist/session/no-session-watch.js +10 -2
- package/dist/session/pending-commands.js +1 -0
- package/dist/session/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +3 -0
- package/dist/session/session-manager.js +72 -3
- package/dist/session/session.d.ts +1 -0
- package/dist/session/session.js +8 -0
- package/dist/telemetry/crash-cause.d.ts +47 -0
- package/dist/telemetry/crash-cause.js +104 -0
- package/dist/telemetry/daemon-telemetry.d.ts +25 -7
- package/dist/telemetry/daemon-telemetry.js +28 -13
- package/dist/telemetry/error-class.d.ts +26 -0
- package/dist/telemetry/error-class.js +52 -0
- package/dist/telemetry/feedback-cli.d.ts +7 -2
- package/dist/telemetry/feedback-cli.js +49 -5
- package/dist/telemetry/feedback-context.d.ts +1 -6
- package/dist/telemetry/feedback-context.js +64 -4
- package/dist/telemetry/git-facts.d.ts +7 -0
- package/dist/telemetry/git-facts.js +4 -4
- package/dist/telemetry/project-profile.js +2 -1
- package/dist/telemetry/session-metrics.d.ts +45 -3
- package/dist/telemetry/session-metrics.js +247 -9
- package/dist/telemetry/telemetry.d.ts +19 -1
- package/dist/telemetry/telemetry.js +31 -8
- package/dist/tools/act-tools.js +33 -6
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +15 -3
- package/dist/tools/browser-tools.js +8 -2
- package/dist/tools/contract-tools.d.ts +0 -5
- package/dist/tools/contract-tools.js +26 -0
- package/dist/tools/dynamic-tools.js +6 -1
- package/dist/tools/error-recovery.d.ts +14 -0
- package/dist/tools/error-recovery.js +83 -7
- package/dist/tools/feedback-invite.d.ts +48 -0
- package/dist/tools/feedback-invite.js +71 -0
- package/dist/tools/invoke-tool.d.ts +0 -6
- package/dist/tools/invoke-tool.js +69 -1
- package/dist/tools/navigate-arrival.d.ts +26 -0
- package/dist/tools/navigate-arrival.js +60 -0
- package/dist/tools/navigate-result.d.ts +15 -1
- package/dist/tools/navigate-result.js +17 -10
- package/dist/tools/observe-tools.js +22 -4
- package/dist/tools/real-input-attempt.js +10 -5
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.js +13 -0
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +2 -2
- package/docs/benchmarks.md +1 -1
- package/docs/fixtures-dispatch-receiver.yml +76 -0
- package/docs/fixtures.md +17 -0
- package/docs/gate-plan.md +98 -0
- package/docs/gates.md +117 -0
- package/docs/local-registry.md +1 -1
- package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
- package/docs/matrix/2.5.0/codex-machine.json +20 -0
- package/docs/matrix/2.5.0/cursor-machine.json +22 -0
- package/docs/matrix/2.5.0/gemini-machine.json +22 -0
- package/docs/matrix/2.5.0/opencode-machine.json +22 -0
- package/docs/matrix/2.5.0/vscode-machine.json +22 -0
- package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
- package/docs/matrix/MATRIX.md +26 -0
- package/docs/matrix/README.md +59 -0
- package/docs/mint.json +1 -1
- package/docs/system-map.md +152 -0
- package/docs/telemetry-contract.md +2 -2
- package/docs/telemetry.md +7 -0
- package/package.json +2 -2
|
@@ -9,7 +9,13 @@ import { describeObserved } from './observed-in-window.js';
|
|
|
9
9
|
* the only key supplied left a predicate that asserts nothing and passes on anything.
|
|
10
10
|
*/
|
|
11
11
|
const PREDICATE_ALIASES = {
|
|
12
|
-
|
|
12
|
+
// `urlContains`/`url` reported from the field: an agent that had just written
|
|
13
|
+
// `net { urlContains }` applied the same word to `route`, which spells it `contains`, and got
|
|
14
|
+
// `unrecognized_keys` with no list of what would have worked. The parallel it assumed is a fair
|
|
15
|
+
// one — route's `contains` matches the WHOLE route (path + query + fragment), so "the URL
|
|
16
|
+
// contains this" is precisely what it does — and asserting a redirect after login is the single
|
|
17
|
+
// most common thing an agent reaches for here.
|
|
18
|
+
[PredicateKind.ROUTE]: { path: 'pathname', urlContains: 'contains', url: 'contains' },
|
|
13
19
|
[PredicateKind.NET]: { url: 'urlContains' },
|
|
14
20
|
[PredicateKind.SIGNAL]: { data: 'dataMatches' },
|
|
15
21
|
};
|
|
@@ -208,6 +214,22 @@ function callSucceeded(data) {
|
|
|
208
214
|
const status = num(data['status']);
|
|
209
215
|
return status === undefined || status < 400;
|
|
210
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* One call, as the failure report should name it: `POST /api/generate-script → 500`.
|
|
219
|
+
*
|
|
220
|
+
* The status used to be dropped here, which made the most common net failure unreadable. Asserting
|
|
221
|
+
* `{status: 200}` against a call that returned 500 reported only `POST /api/generate-script` — the
|
|
222
|
+
* very field the predicate filtered on was missing from the account of what was seen, so the agent
|
|
223
|
+
* is told the call happened and left to guess why it did not match.
|
|
224
|
+
*
|
|
225
|
+
* The arrow is OMITTED when there is no status. An in-flight or aborted request genuinely has none,
|
|
226
|
+
* and `→ undefined` would be a fabricated fact about the wire.
|
|
227
|
+
*/
|
|
228
|
+
function describeCall(e) {
|
|
229
|
+
const head = `${str(e.data['method']) ?? 'GET'} ${str(e.data['url']) ?? ''}`;
|
|
230
|
+
const status = num(e.data['status']);
|
|
231
|
+
return status === undefined ? head : `${head} → ${String(status)}`;
|
|
232
|
+
}
|
|
211
233
|
export function evalNet(events, p) {
|
|
212
234
|
const since = p.since ?? 0;
|
|
213
235
|
const matches = events.filter((e) => {
|
|
@@ -248,9 +270,7 @@ export function evalNet(events, p) {
|
|
|
248
270
|
failureReason: `no network call matched ${JSON.stringify(p)}`,
|
|
249
271
|
// Same reasoning as the signal miss: "no matching call" cannot be told apart from "the app
|
|
250
272
|
// made no calls at all", and those need different fixes.
|
|
251
|
-
observed: describeObserved('calls', events
|
|
252
|
-
.filter((e) => e.type === EventType.NET_REQUEST)
|
|
253
|
-
.map((e) => `${str(e.data['method']) ?? 'GET'} ${str(e.data['url']) ?? ''}`)),
|
|
273
|
+
observed: describeObserved('calls', events.filter((e) => e.type === EventType.NET_REQUEST).map(describeCall)),
|
|
254
274
|
expected: `at least one call matching ${JSON.stringify(p)}`,
|
|
255
275
|
assertion: 'net.present',
|
|
256
276
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One readable sentence when a predicate does not parse — never the zod array.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-08-10: **9 of 58 tool errors that day were a serialized zod issue array**, all on
|
|
5
|
+
* `reticle_act_and_wait`, `reticle_wait_for` and `reticle_assert` — the three tools that produced
|
|
6
|
+
* every action-derived finding in the dataset. The least readable error we emit was landing on the
|
|
7
|
+
* highest-value path, and an agent had to `JSON.parse` an error string to learn which field it got
|
|
8
|
+
* wrong.
|
|
9
|
+
*
|
|
10
|
+
* `PredicateSchema.parse()` throws a `ZodError` whose `.message` IS that array, and three handlers
|
|
11
|
+
* call it directly. This wraps it once, so every caller gets the same shape rather than each
|
|
12
|
+
* growing its own catch — the pattern #108 asks for: name the parameter, say whether anything ran,
|
|
13
|
+
* show a valid call.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { PredicateSchema } from './predicate-eval.js';
|
|
17
|
+
/**
|
|
18
|
+
* Parse a predicate, or throw an Error whose message is a sentence.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately still THROWS: every call site already handles a throw, and turning this into a
|
|
21
|
+
* result type would mean touching three handlers to gain nothing the message does not already say.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parsePredicate(input: unknown): z.infer<typeof PredicateSchema>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One readable sentence when a predicate does not parse — never the zod array.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-08-10: **9 of 58 tool errors that day were a serialized zod issue array**, all on
|
|
5
|
+
* `reticle_act_and_wait`, `reticle_wait_for` and `reticle_assert` — the three tools that produced
|
|
6
|
+
* every action-derived finding in the dataset. The least readable error we emit was landing on the
|
|
7
|
+
* highest-value path, and an agent had to `JSON.parse` an error string to learn which field it got
|
|
8
|
+
* wrong.
|
|
9
|
+
*
|
|
10
|
+
* `PredicateSchema.parse()` throws a `ZodError` whose `.message` IS that array, and three handlers
|
|
11
|
+
* call it directly. This wraps it once, so every caller gets the same shape rather than each
|
|
12
|
+
* growing its own catch — the pattern #108 asks for: name the parameter, say whether anything ran,
|
|
13
|
+
* show a valid call.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { PredicateSchema } from './predicate-eval.js';
|
|
17
|
+
/** A valid call, short enough to sit inside an error without becoming the error. */
|
|
18
|
+
const EXAMPLE = '{ kind: "signal", name: "todos:loaded" }';
|
|
19
|
+
/** `path: ["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
|
|
20
|
+
function pathOf(issue) {
|
|
21
|
+
return 0 === issue.path.length ? 'the predicate' : issue.path.map(String).join('.');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One issue, as a clause a human or an agent can act on.
|
|
25
|
+
*
|
|
26
|
+
* `unrecognized_keys` is the common case by far and the one worth spelling out — it is what a
|
|
27
|
+
* plausible-but-wrong field name produces, and the key itself is the whole answer.
|
|
28
|
+
*/
|
|
29
|
+
function describeIssue(issue) {
|
|
30
|
+
if (z.ZodIssueCode.unrecognized_keys === issue.code) {
|
|
31
|
+
return `unknown field ${issue.keys.join(', ')}`;
|
|
32
|
+
}
|
|
33
|
+
return `${pathOf(issue)}: ${issue.message}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse a predicate, or throw an Error whose message is a sentence.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately still THROWS: every call site already handles a throw, and turning this into a
|
|
39
|
+
* result type would mean touching three handlers to gain nothing the message does not already say.
|
|
40
|
+
*/
|
|
41
|
+
export function parsePredicate(input) {
|
|
42
|
+
const parsed = PredicateSchema.safeParse(input);
|
|
43
|
+
if (parsed.success)
|
|
44
|
+
return parsed.data;
|
|
45
|
+
const kind = 'object' === typeof input && null !== input && 'kind' in input
|
|
46
|
+
? String(input['kind'])
|
|
47
|
+
: 'unknown';
|
|
48
|
+
// Bounded on purpose: a union rejection can produce one issue per member, and pasting all of them
|
|
49
|
+
// back is how the zod array became unreadable in the first place.
|
|
50
|
+
const issues = parsed.error.issues.slice(0, 3).map(describeIssue).join('; ');
|
|
51
|
+
throw new Error(`that predicate did not parse (kind "${kind}"): ${issues}. Nothing ran — the predicate was ` +
|
|
52
|
+
`not evaluated, so no verdict was produced. Call reticle_tools for the fields this kind ` +
|
|
53
|
+
`accepts. A valid call looks like: ${EXAMPLE}`);
|
|
54
|
+
}
|
|
@@ -17,6 +17,11 @@ export interface PredicateSession {
|
|
|
17
17
|
* Optional: a session without ambient learning simply omits it and settle behaves as before.
|
|
18
18
|
*/
|
|
19
19
|
ambientCounts?(): AmbientCounts;
|
|
20
|
+
/**
|
|
21
|
+
* Subscribe to session disconnect. Returns an unsubscribe function. Optional: a session without
|
|
22
|
+
* this hook (e.g. tests that never disconnect) simply leaves in-flight predicates until timeout.
|
|
23
|
+
*/
|
|
24
|
+
onDisconnect?(listener: () => void): () => void;
|
|
20
25
|
}
|
|
21
26
|
export declare function evaluatePredicate(session: PredicateSession, predicate: Predicate, since?: number, diagnose?: boolean): Promise<EvalResult>;
|
|
22
27
|
/**
|
package/dist/events/predicate.js
CHANGED
|
@@ -305,6 +305,7 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
|
|
|
305
305
|
return;
|
|
306
306
|
done = true;
|
|
307
307
|
unsub();
|
|
308
|
+
unsubDisconnect?.();
|
|
308
309
|
clearInterval(interval);
|
|
309
310
|
clearTimeout(timer);
|
|
310
311
|
if (cooldownTimer !== undefined)
|
|
@@ -398,6 +399,13 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
|
|
|
398
399
|
* rejection reached it as an unhandledRejection. Either way the wait never resolved and the tool
|
|
399
400
|
* handler never returned — the agent sees a call that simply never comes back.
|
|
400
401
|
*
|
|
402
|
+
* This half covers the SYNCHRONOUS throw only. The rejection half is handled inside `check`, by
|
|
403
|
+
* the `.catch` on its own promise chain — `check` returns void and voids the promise it starts,
|
|
404
|
+
* so there is nothing to hand back here to await. Said explicitly because the obvious-looking
|
|
405
|
+
* `if (result instanceof Promise) result.catch(…)` that used to sit here was dead code that read
|
|
406
|
+
* like the rejection guard, and removing `check`'s own `.catch` as redundant would have restored
|
|
407
|
+
* the exact hang this exists to prevent.
|
|
408
|
+
*
|
|
401
409
|
* That is the exact shape reported from a Plane (Next 14 + MobX) session, which tears the page
|
|
402
410
|
* session down and rebuilds it on EVERY navigation, so a `{kind:"route"}` predicate always races
|
|
403
411
|
* a teardown of the very session it is watching: `browser.command ok:true` for the click, and
|
|
@@ -407,9 +415,7 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
|
|
|
407
415
|
*/
|
|
408
416
|
const guardedCheck = () => {
|
|
409
417
|
try {
|
|
410
|
-
|
|
411
|
-
if (pending instanceof Promise)
|
|
412
|
-
pending.catch(failWait);
|
|
418
|
+
check();
|
|
413
419
|
}
|
|
414
420
|
catch (error) {
|
|
415
421
|
failWait(error);
|
|
@@ -424,6 +430,14 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
|
|
|
424
430
|
const unsub = session.onEvent(() => {
|
|
425
431
|
boundCheck();
|
|
426
432
|
});
|
|
433
|
+
const unsubDisconnect = session.onDisconnect?.(() => {
|
|
434
|
+
// `observationLost` is what stops this being graded as an app defect. `pass: false` is still
|
|
435
|
+
// correct — the consequence was not seen to hold — but on its own it reached the verdict rule
|
|
436
|
+
// as ASSERTION_FAILED, so a reload mid-wait reported "the declared consequence did not hold"
|
|
437
|
+
// against a healthy component, by file and line. The flag is structured rather than inferred
|
|
438
|
+
// from this string, because every other `failureReason` here is prose about the APP.
|
|
439
|
+
finish({ pass: false, failureReason: 'session disconnected', observationLost: true });
|
|
440
|
+
});
|
|
427
441
|
const interval = setInterval(boundCheck, POLL_INTERVAL_MS);
|
|
428
442
|
const timer = setTimeout(() => {
|
|
429
443
|
void evaluatePredicate(session, predicate, since)
|
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
* The high-volume, low-signal floor. These fire continuously on any live page (count-up text, CSS
|
|
4
|
-
* animation frames, React commits, health heartbeats, scroll) and are the ONLY thing that should be
|
|
5
|
-
* sacrificed when the buffer is full. Everything else — a failed request, a console error, a domain
|
|
6
|
-
* signal, a truncation/blind-spot marker — is scarce evidence a verdict depends on, and a churning
|
|
7
|
-
* region must never be able to push it out of the window.
|
|
8
|
-
*/
|
|
9
|
-
const CHURN_TYPES = new Set([
|
|
10
|
-
EventType.DOM_TEXT,
|
|
11
|
-
EventType.ANIM_START,
|
|
12
|
-
EventType.ANIM_END,
|
|
13
|
-
EventType.RENDER_COMMIT,
|
|
14
|
-
EventType.PAGE_HEALTH,
|
|
15
|
-
EventType.SCROLL_POSITION,
|
|
16
|
-
]);
|
|
1
|
+
import { CHURN_TYPES, RING_BUFFER_DEFAULTS } from '@reticlehq/core';
|
|
17
2
|
/** How far forward to look for a churn event to sacrifice before falling back to plain FIFO. */
|
|
18
3
|
const CHURN_SCAN_LIMIT = 256;
|
|
19
4
|
/**
|
|
@@ -49,7 +49,7 @@ export type Sleep = (ms: number) => Promise<void>;
|
|
|
49
49
|
export declare function nearestIsAmbiguous(missing: string, present: string[]): boolean;
|
|
50
50
|
/** Build the legible-drift record for a testid anchor that resolved to zero live elements. */
|
|
51
51
|
export declare function testidDrift(value: string, hint: QueryEmptyHint | undefined): Drift;
|
|
52
|
-
export declare function resolveQuery(session: FlowReplaySession, queryArgs: Record<string, unknown>, sleep: Sleep): Promise<{
|
|
52
|
+
export declare function resolveQuery(session: FlowReplaySession, queryArgs: Record<string, unknown>, sleep: Sleep, now?: () => number): Promise<{
|
|
53
53
|
refs: string[];
|
|
54
54
|
hint?: QueryEmptyHint;
|
|
55
55
|
}>;
|
|
@@ -61,6 +61,22 @@ export function nearestTestid(missing, present) {
|
|
|
61
61
|
*/
|
|
62
62
|
const ANCHOR_SETTLE_ATTEMPTS = 8;
|
|
63
63
|
const ANCHOR_SETTLE_DELAY_MS = 150;
|
|
64
|
+
/**
|
|
65
|
+
* The budget that actually governs, in wall-clock milliseconds.
|
|
66
|
+
*
|
|
67
|
+
* Attempts were the bound, and on an event-chatty page that collapsed. `settleTick` ends on EITHER
|
|
68
|
+
* an event OR the tick, so a page emitting continuously (API calls, a large form render, CSS
|
|
69
|
+
* transition start/end pairs) burned all eight attempts in **224–758ms measured** — a budget
|
|
70
|
+
* documented as 1.2s, spent in a fifth of it, before a newly routed page had mounted its controls.
|
|
71
|
+
* Cross-route replays drifted `testid_not_found` at 278ms while the same flow passed on a quiet page.
|
|
72
|
+
*
|
|
73
|
+
* An event arriving is evidence the page is still working. It should EXTEND the wait, not spend it.
|
|
74
|
+
* So the deadline decides when to give up, and the attempt cap below survives only as a backstop
|
|
75
|
+
* against a pathological storm spinning this loop hot.
|
|
76
|
+
*/
|
|
77
|
+
const ANCHOR_SETTLE_BUDGET_MS = ANCHOR_SETTLE_ATTEMPTS * ANCHOR_SETTLE_DELAY_MS;
|
|
78
|
+
/** Backstop only. Generous on purpose: the deadline is the real bound, this just prevents a spin. */
|
|
79
|
+
const ANCHOR_SETTLE_MAX_ATTEMPTS = 40;
|
|
64
80
|
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
65
81
|
/** Extract the live element refs + the zero-match near-miss hint from a QUERY command result. */
|
|
66
82
|
function readQuery(result) {
|
|
@@ -158,9 +174,10 @@ async function settleTick(session, sleep) {
|
|
|
158
174
|
unsubscribe?.();
|
|
159
175
|
}
|
|
160
176
|
}
|
|
161
|
-
export async function resolveQuery(session, queryArgs, sleep) {
|
|
177
|
+
export async function resolveQuery(session, queryArgs, sleep, now = Date.now) {
|
|
162
178
|
let last = readQuery(await session.command(ReticleCommand.QUERY, queryArgs));
|
|
163
|
-
|
|
179
|
+
const deadline = now() + ANCHOR_SETTLE_BUDGET_MS;
|
|
180
|
+
for (let attempt = 1; 0 === last.refs.length && now() < deadline && attempt < ANCHOR_SETTLE_MAX_ATTEMPTS; attempt += 1) {
|
|
164
181
|
await settleTick(session, sleep);
|
|
165
182
|
last = readQuery(await session.command(ReticleCommand.QUERY, queryArgs));
|
|
166
183
|
}
|
|
@@ -11,6 +11,7 @@ import { ReticleTool } from '../tools/tool-names.js';
|
|
|
11
11
|
import { replayActionArgs } from './replay.js';
|
|
12
12
|
import { anchorLabel, componentLabel, componentQueryArgs, resolveQuery, testidDrift, } from './flow-replay.js';
|
|
13
13
|
import { nearestRoleName } from './role-anchor-nearest.js';
|
|
14
|
+
import { roleDriftReason } from './role-drift-reason.js';
|
|
14
15
|
/** Query args for a NAMED role anchor — the handle that identifies an instance, not a JSX site. */
|
|
15
16
|
function roleQueryArgs(anchor) {
|
|
16
17
|
return {
|
|
@@ -74,9 +75,7 @@ export async function runRoleStep(session, step, index, anchor, confirmDangerous
|
|
|
74
75
|
ok: false,
|
|
75
76
|
drift: {
|
|
76
77
|
reasonKind: DriftReason.COMPONENT_NOT_FOUND,
|
|
77
|
-
reason:
|
|
78
|
-
? `role anchor ${label} not found, and no surviving ${anchor.role} has a similar name — anchor this step to a data-testid`
|
|
79
|
-
: `role anchor ${label} not found; the closest surviving ${anchor.role} is "${nearest}" — confirm it is the same control before rebinding, or add a data-testid`,
|
|
78
|
+
reason: roleDriftReason(anchor.role, String(anchor.name), nearest),
|
|
80
79
|
anchor: label,
|
|
81
80
|
nearest,
|
|
82
81
|
},
|
package/dist/flows/flow-tools.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { emptyFlowRefusal } from './empty-flow.js';
|
|
3
3
|
import { aliasParam } from '../tools/alias-args.js';
|
|
4
4
|
import { FlowErrorCode, RecordedSaveError, ReplayStatus, } from '@reticlehq/core';
|
|
5
|
-
import {
|
|
5
|
+
import { recordSuiteFlakes } from './suite-flakes.js';
|
|
6
6
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
7
7
|
import { asNumber, asString } from '../tools/tools-helpers.js';
|
|
8
8
|
import { log } from '../log.js';
|
|
@@ -383,18 +383,15 @@ export const FLOW_TOOLS = [
|
|
|
383
383
|
const flow = loaded !== null && loaded.ok ? loaded.value : undefined;
|
|
384
384
|
return flow === undefined ? { replay } : { replay, flow };
|
|
385
385
|
}));
|
|
386
|
-
|
|
386
|
+
const flaky = await recordSuiteFlakes(deps.fs, deps.reticleRoot, parallelRuns);
|
|
387
|
+
const verdict = buildSuiteVerdict(parallelRuns);
|
|
388
|
+
return flaky.length > 0 ? { ...verdict, flaky: [...flaky] } : verdict;
|
|
387
389
|
}
|
|
388
390
|
// The flow FILE travels with each replay so the verdict can tell a green that verified
|
|
389
391
|
// something from a green that could never have gone red. Without it the suite reported "all 1
|
|
390
392
|
// flow pass" for a flow with no steps at all.
|
|
391
393
|
const runs = [];
|
|
392
394
|
const timed = [];
|
|
393
|
-
// The flake ledger the CLI gate already keeps — see FlakeStore. It was only ever written by
|
|
394
|
-
// `reticle flow` on the command line, so an AGENT running this tool a hundred times learned
|
|
395
|
-
// nothing about which flows are intermittent. Same product, same ledger, two surfaces, and the
|
|
396
|
-
// one an agent uses was the blind half.
|
|
397
|
-
const flakes = new FlakeStore(deps.fs, deps.reticleRoot);
|
|
398
395
|
// Sequential default: every flow replays against the same live session, so they must not overlap.
|
|
399
396
|
for (const flowName of requested) {
|
|
400
397
|
const start = deps.now();
|
|
@@ -404,19 +401,18 @@ export const FLOW_TOOLS = [
|
|
|
404
401
|
runs.push(flow === undefined ? { replay } : { replay, flow });
|
|
405
402
|
timed.push({ replay, durationMs: deps.now() - start });
|
|
406
403
|
}
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const flaky = await flakes.flakyFlows().catch(() => []);
|
|
404
|
+
// The flake ledger the CLI gate already keeps — see FlakeStore. It was only ever written by
|
|
405
|
+
// `reticle flow` on the command line, so an AGENT running this tool a hundred times learned
|
|
406
|
+
// nothing about which flows are intermittent. Same product, same ledger, two surfaces, and the
|
|
407
|
+
// one an agent uses was the blind half.
|
|
408
|
+
const flaky = await recordSuiteFlakes(deps.fs, deps.reticleRoot, runs);
|
|
413
409
|
// Emit the consolidated run artifact (Runs tab) + best-effort cloud push. Never blocks the verdict.
|
|
414
410
|
await persistAndSyncVerificationRun(deps, timed, projectId);
|
|
415
411
|
const verdict = buildSuiteVerdict(runs);
|
|
416
412
|
// A flow that has both passed and failed on UNCHANGED code is a different thing from a
|
|
417
413
|
// regression, and an agent that cannot tell them apart either chases a ghost or ignores a real
|
|
418
414
|
// break. Present only when the ledger has seen enough runs to say so.
|
|
419
|
-
return flaky.length > 0 ? { ...verdict, flaky } : verdict;
|
|
415
|
+
return flaky.length > 0 ? { ...verdict, flaky: [...flaky] } : verdict;
|
|
420
416
|
},
|
|
421
417
|
},
|
|
422
418
|
{
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to say when a role+name anchor no longer resolves.
|
|
3
|
+
*
|
|
4
|
+
* The old message could contradict itself in the one case that matters most:
|
|
5
|
+
*
|
|
6
|
+
* role anchor button "Add User" not found; the closest surviving button is "Add User"
|
|
7
|
+
*
|
|
8
|
+
* Read literally that says the control both is and is not there, and it cost a reporter real
|
|
9
|
+
* debugging time. What it means is more useful than a rename: the stored name and the live
|
|
10
|
+
* accessible name are the SAME STRING, so the name is not what changed — the query matched nothing
|
|
11
|
+
* for some other reason.
|
|
12
|
+
*
|
|
13
|
+
* In the reported case the recorded name came from a `placeholder` — `reticle_query` reports
|
|
14
|
+
* `name:"Search User"` for `<input type="search" placeholder="Search User">` with no label — and the
|
|
15
|
+
* replay resolver computes accessible names differently, so the round trip cannot close. That is a
|
|
16
|
+
* real bug in its own right (two accessible-name implementations disagreeing), and while it is open
|
|
17
|
+
* the message is the only thing the reader has, because `flow_heal` correctly refuses to rebind a
|
|
18
|
+
* role anchor on its own.
|
|
19
|
+
*/
|
|
20
|
+
export declare function roleDriftReason(role: string, name: string, nearest: string | null): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to say when a role+name anchor no longer resolves.
|
|
3
|
+
*
|
|
4
|
+
* The old message could contradict itself in the one case that matters most:
|
|
5
|
+
*
|
|
6
|
+
* role anchor button "Add User" not found; the closest surviving button is "Add User"
|
|
7
|
+
*
|
|
8
|
+
* Read literally that says the control both is and is not there, and it cost a reporter real
|
|
9
|
+
* debugging time. What it means is more useful than a rename: the stored name and the live
|
|
10
|
+
* accessible name are the SAME STRING, so the name is not what changed — the query matched nothing
|
|
11
|
+
* for some other reason.
|
|
12
|
+
*
|
|
13
|
+
* In the reported case the recorded name came from a `placeholder` — `reticle_query` reports
|
|
14
|
+
* `name:"Search User"` for `<input type="search" placeholder="Search User">` with no label — and the
|
|
15
|
+
* replay resolver computes accessible names differently, so the round trip cannot close. That is a
|
|
16
|
+
* real bug in its own right (two accessible-name implementations disagreeing), and while it is open
|
|
17
|
+
* the message is the only thing the reader has, because `flow_heal` correctly refuses to rebind a
|
|
18
|
+
* role anchor on its own.
|
|
19
|
+
*/
|
|
20
|
+
/** Same name to a reader: case and surrounding whitespace are not a rename. */
|
|
21
|
+
function sameName(a, b) {
|
|
22
|
+
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
export function roleDriftReason(role, name, nearest) {
|
|
25
|
+
const label = `${role} "${name}"`;
|
|
26
|
+
if (null === nearest) {
|
|
27
|
+
return (`role anchor ${label} not found, and no surviving ${role} has a similar name — ` +
|
|
28
|
+
'anchor this step to a data-testid');
|
|
29
|
+
}
|
|
30
|
+
if (sameName(name, nearest)) {
|
|
31
|
+
// The one case the old wording made unreadable. Say what is ruled OUT — that is the information.
|
|
32
|
+
return (`role anchor ${label} did not resolve, but a ${role} with an identical name is on the page — ` +
|
|
33
|
+
'so this is NOT a rename and editing the label will not fix it. The recorded name and the ' +
|
|
34
|
+
'name the resolver computes disagree, which happens when the recorded one came from a ' +
|
|
35
|
+
'placeholder or title rather than a label or aria-label. Anchor this step to a data-testid.');
|
|
36
|
+
}
|
|
37
|
+
return (`role anchor ${label} not found; the closest surviving ${role} is "${nearest}" — ` +
|
|
38
|
+
'confirm it is the same control before rebinding, or add a data-testid');
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accrue a suite run's outcomes into the flake ledger, and report what the ledger knows.
|
|
3
|
+
*
|
|
4
|
+
* This existed twice — once in `flow_verify`'s sequential branch and once, after #240, in its
|
|
5
|
+
* parallel branch. That duplication is not incidental: the parallel branch shipped WITHOUT it, so an
|
|
6
|
+
* agent verifying in parallel built no flake evidence at all and never saw the quarantine set, and
|
|
7
|
+
* nothing went red because the other copy was still correct. One function both branches call is what
|
|
8
|
+
* makes that miss impossible on one side only.
|
|
9
|
+
*
|
|
10
|
+
* It is also what makes the behaviour testable. The handler itself needs a live browser session, so
|
|
11
|
+
* both branches were only ever pinned by tests that RE-IMPLEMENTED this loop — which passed with the
|
|
12
|
+
* shipped code reverted (measured on #240: 4/4 green with the fix absent). Extracted, the real thing
|
|
13
|
+
* runs in a unit test.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort on both halves, deliberately: a flake ledger is memory, not a gate. A full disk must
|
|
16
|
+
* never turn a working verification into an error.
|
|
17
|
+
*/
|
|
18
|
+
import { ReplayStatus } from '@reticlehq/core';
|
|
19
|
+
import type { FileSystemPort } from '../project/fs-port.js';
|
|
20
|
+
/** Just the shape this needs from a replay — so a test does not have to build a whole result. */
|
|
21
|
+
export interface FlakeOutcome {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly status: ReplayStatus;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Record every outcome, then ask which flows are intermittent.
|
|
27
|
+
*
|
|
28
|
+
* Anything that is not `OK` — `ERROR` or `DRIFT` — counts as a failure for the ledger. That is the
|
|
29
|
+
* rule both call sites already used, preserved rather than chosen here.
|
|
30
|
+
*/
|
|
31
|
+
export declare function recordSuiteFlakes(fs: FileSystemPort, reticleRoot: string, runs: readonly {
|
|
32
|
+
readonly replay: FlakeOutcome;
|
|
33
|
+
}[]): Promise<readonly string[]>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accrue a suite run's outcomes into the flake ledger, and report what the ledger knows.
|
|
3
|
+
*
|
|
4
|
+
* This existed twice — once in `flow_verify`'s sequential branch and once, after #240, in its
|
|
5
|
+
* parallel branch. That duplication is not incidental: the parallel branch shipped WITHOUT it, so an
|
|
6
|
+
* agent verifying in parallel built no flake evidence at all and never saw the quarantine set, and
|
|
7
|
+
* nothing went red because the other copy was still correct. One function both branches call is what
|
|
8
|
+
* makes that miss impossible on one side only.
|
|
9
|
+
*
|
|
10
|
+
* It is also what makes the behaviour testable. The handler itself needs a live browser session, so
|
|
11
|
+
* both branches were only ever pinned by tests that RE-IMPLEMENTED this loop — which passed with the
|
|
12
|
+
* shipped code reverted (measured on #240: 4/4 green with the fix absent). Extracted, the real thing
|
|
13
|
+
* runs in a unit test.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort on both halves, deliberately: a flake ledger is memory, not a gate. A full disk must
|
|
16
|
+
* never turn a working verification into an error.
|
|
17
|
+
*/
|
|
18
|
+
import { ReplayStatus } from '@reticlehq/core';
|
|
19
|
+
import { FlakeStore } from './flake-store.js';
|
|
20
|
+
/**
|
|
21
|
+
* Record every outcome, then ask which flows are intermittent.
|
|
22
|
+
*
|
|
23
|
+
* Anything that is not `OK` — `ERROR` or `DRIFT` — counts as a failure for the ledger. That is the
|
|
24
|
+
* rule both call sites already used, preserved rather than chosen here.
|
|
25
|
+
*/
|
|
26
|
+
export async function recordSuiteFlakes(fs, reticleRoot, runs) {
|
|
27
|
+
const flakes = new FlakeStore(fs, reticleRoot);
|
|
28
|
+
for (const { replay } of runs) {
|
|
29
|
+
await flakes.record(replay.name, ReplayStatus.OK === replay.status).catch(() => undefined);
|
|
30
|
+
}
|
|
31
|
+
return flakes.flakyFlows().catch(() => []);
|
|
32
|
+
}
|
|
@@ -65,3 +65,22 @@ export declare function blindSpotsFromEvents(events: readonly ReticleEvent[]): B
|
|
|
65
65
|
* from what happened to be said recently.
|
|
66
66
|
*/
|
|
67
67
|
export declare function blindSpotsFromState(state: Readonly<Record<string, number>>): BlindSpot[];
|
|
68
|
+
/**
|
|
69
|
+
* Events the BROWSER's own transport queue threw away inside this window, or 0.
|
|
70
|
+
*
|
|
71
|
+
* `RATE_LIMITED` — the BRIDGE sampling because events arrived faster than its per-second cap — is
|
|
72
|
+
* already the one blind spot that `impeachesCapture`, on the reasoning that a green over a window
|
|
73
|
+
* you did not fully see "would only describe what was observed". `TRANSPORT_OVERFLOW` is the exact
|
|
74
|
+
* same loss on the other side of the wire, and it was read in journal rollups and NOWHERE on the
|
|
75
|
+
* verdict path. So the identical condition downgraded a verdict from one side of the socket and was
|
|
76
|
+
* invisible from the other: a window that dropped 34 events graded `proved`, in a sentence whose own
|
|
77
|
+
* words were "over a clean capture".
|
|
78
|
+
*
|
|
79
|
+
* `TRUNCATED` is deliberately NOT counted. It names the channel it capped (a DOM-mutation flood on
|
|
80
|
+
* any busy page) and is routine churn; treating it as lost evidence would caveat every verdict on
|
|
81
|
+
* every real app, and a caveat that is always present is one nobody reads. TRANSPORT_OVERFLOW is the
|
|
82
|
+
* honest "arbitrary events are gone" marker — it cannot say which.
|
|
83
|
+
*/
|
|
84
|
+
export declare function droppedByTransport(events: readonly ReticleEvent[]): number;
|
|
85
|
+
/** The impeaching note for a transport gap, or undefined when the window was intact. */
|
|
86
|
+
export declare function transportGapNote(events: readonly ReticleEvent[]): string | undefined;
|
|
@@ -109,3 +109,36 @@ export function blindSpotsFromEvents(events) {
|
|
|
109
109
|
export function blindSpotsFromState(state) {
|
|
110
110
|
return Object.entries(state).map(([kind, count]) => ({ kind: kind, count }));
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Events the BROWSER's own transport queue threw away inside this window, or 0.
|
|
114
|
+
*
|
|
115
|
+
* `RATE_LIMITED` — the BRIDGE sampling because events arrived faster than its per-second cap — is
|
|
116
|
+
* already the one blind spot that `impeachesCapture`, on the reasoning that a green over a window
|
|
117
|
+
* you did not fully see "would only describe what was observed". `TRANSPORT_OVERFLOW` is the exact
|
|
118
|
+
* same loss on the other side of the wire, and it was read in journal rollups and NOWHERE on the
|
|
119
|
+
* verdict path. So the identical condition downgraded a verdict from one side of the socket and was
|
|
120
|
+
* invisible from the other: a window that dropped 34 events graded `proved`, in a sentence whose own
|
|
121
|
+
* words were "over a clean capture".
|
|
122
|
+
*
|
|
123
|
+
* `TRUNCATED` is deliberately NOT counted. It names the channel it capped (a DOM-mutation flood on
|
|
124
|
+
* any busy page) and is routine churn; treating it as lost evidence would caveat every verdict on
|
|
125
|
+
* every real app, and a caveat that is always present is one nobody reads. TRANSPORT_OVERFLOW is the
|
|
126
|
+
* honest "arbitrary events are gone" marker — it cannot say which.
|
|
127
|
+
*/
|
|
128
|
+
export function droppedByTransport(events) {
|
|
129
|
+
let dropped = 0;
|
|
130
|
+
for (const e of events) {
|
|
131
|
+
if (e.type !== EventType.TRANSPORT_OVERFLOW)
|
|
132
|
+
continue;
|
|
133
|
+
const n = e.data['dropped'];
|
|
134
|
+
dropped += 'number' === typeof n ? n : 0;
|
|
135
|
+
}
|
|
136
|
+
return dropped;
|
|
137
|
+
}
|
|
138
|
+
/** The impeaching note for a transport gap, or undefined when the window was intact. */
|
|
139
|
+
export function transportGapNote(events) {
|
|
140
|
+
const dropped = droppedByTransport(events);
|
|
141
|
+
return 0 === dropped
|
|
142
|
+
? undefined
|
|
143
|
+
: `the browser dropped ${String(dropped)} event(s) in this window (transport queue overflow), so events that would have contradicted this may never have arrived`;
|
|
144
|
+
}
|
|
@@ -14,6 +14,15 @@ import { type HonestyBlock } from './honesty.js';
|
|
|
14
14
|
interface VerifiedInputs {
|
|
15
15
|
/** Did the declared consequence hold? Undefined when the action declared none. */
|
|
16
16
|
pass?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The wait ended because the TAB went away, not because the app did anything.
|
|
19
|
+
*
|
|
20
|
+
* A structured flag rather than a match on `failureReason`, which is free prose everywhere else it
|
|
21
|
+
* is produced and describes what the APP did. A string test here would silently reclassify the
|
|
22
|
+
* next app-side reason that happened to mention a disconnect, which is the same
|
|
23
|
+
* confidently-wrong-by-accident this clause exists to remove.
|
|
24
|
+
*/
|
|
25
|
+
observationLost?: boolean;
|
|
17
26
|
honesty: HonestyBlock;
|
|
18
27
|
/**
|
|
19
28
|
* Set when the assertion could not be EVALUATED at all — an under-specified call, or nothing
|
|
@@ -47,7 +56,7 @@ interface VerifiedVerdict {
|
|
|
47
56
|
verified: Verified;
|
|
48
57
|
/**
|
|
49
58
|
* WHICH clause below decided this, as a closed enum. `verified` has three values and this rule has
|
|
50
|
-
*
|
|
59
|
+
* eleven clauses, so the verdict alone collapses opposite facts — "Reticle caught a real bug", "the
|
|
51
60
|
* agent wrote a bad predicate" and "Reticle could not see" all arrived as one string. Named here,
|
|
52
61
|
* beside the sentence, so the vocabulary cannot drift from the branches that produce it.
|
|
53
62
|
*/
|
package/dist/honesty/verified.js
CHANGED
|
@@ -12,6 +12,22 @@ export function decideVerified(inputs) {
|
|
|
12
12
|
because: `the assertion could not be evaluated: ${inputs.inconclusive}`,
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
+
// ABOVE the failure clause, because a wait that ended when the tab vanished did not fail — it was
|
|
16
|
+
// never finished. `waitForPredicate` reports that as `pass: false`, and taking it at face value
|
|
17
|
+
// made Reticle blame the app, by file and line, for its own lost connection. Measured twice: a
|
|
18
|
+
// reload 300ms into a wait returned `verified:"no" / assertion_failed` against a healthy Counter.
|
|
19
|
+
//
|
|
20
|
+
// UNKNOWN, for the same reason `unclean_capture` is: the evidence is ABSENT, not negative.
|
|
21
|
+
if (true === inputs.observationLost) {
|
|
22
|
+
return {
|
|
23
|
+
verified: Verified.UNKNOWN,
|
|
24
|
+
verifiedReason: VerifiedReason.OBSERVATION_LOST,
|
|
25
|
+
because: 'the tab disconnected while this action was being observed, so its outcome was never ' +
|
|
26
|
+
'seen — this says nothing about the app. Call reticle_sessions for the current session ' +
|
|
27
|
+
'(a reloaded tab keeps its id; a closed one is gone) and repeat the action if it is safe ' +
|
|
28
|
+
'to repeat',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
15
31
|
// A failed assertion is the most actionable fact there is; it leads — including over
|
|
16
32
|
// `alreadyTrue`, because a condition that held before AND fails now is a real regression.
|
|
17
33
|
if (false === pass) {
|
package/dist/http-server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
3
3
|
import { MCP_SSE_PATH, MCP_MESSAGE_PATH, STATUS_PATH } from '@reticlehq/core';
|
|
4
|
+
import { getSessionMetrics } from './telemetry/session-metrics.js';
|
|
4
5
|
import { noteAgentPeer, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from './version/peer-announce.js';
|
|
5
6
|
import { log } from './log.js';
|
|
6
7
|
import { reportMcpConnected } from './telemetry/mcp-connection.js';
|
|
@@ -85,6 +86,14 @@ export function createSharedServer(options = {}) {
|
|
|
85
86
|
transport.close().catch(() => undefined);
|
|
86
87
|
mcpServer.close().catch(() => undefined);
|
|
87
88
|
log('mcp_client_disconnected', { sessionId: sid });
|
|
89
|
+
// The one part of `endReason` a query cannot derive: "the client detached" and "the agent
|
|
90
|
+
// stopped asking" produce identical counters and are different findings.
|
|
91
|
+
try {
|
|
92
|
+
getSessionMetrics().recordClientLeft();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* a counter must never affect a disconnect path */
|
|
96
|
+
}
|
|
88
97
|
if (0 === transports.size)
|
|
89
98
|
agentPresence?.(false); // last agent detached → it's the human's turn
|
|
90
99
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type { Lease, Launcher, PooledBrowser } from './pool/browser-pool.js';
|
|
|
34
34
|
export { playwrightLauncher, resolveMaxContexts } from './pool/playwright-launcher.js';
|
|
35
35
|
export { appendReticleParams } from './tools/lease-tools.js';
|
|
36
36
|
export { writePid, removePid, isRunning, logPath, readPid, isAlive } from './daemon/daemon.js';
|
|
37
|
+
export { classifyDaemonLife, DaemonEnd, DAEMON_HEARTBEAT_EVENT, DAEMON_HEARTBEAT_MS, type DaemonLife, } from './daemon/heartbeat.js';
|
|
37
38
|
export type { CrawlReport, CrawlAnomaly, CrawlOptions, CrawlSession } from './crawl/crawl.js';
|
|
38
39
|
export { scrollToFind } from './input/scroll-find.js';
|
|
39
40
|
export type { ScrollFindResult, ScrollFindQuery, ScrollFindSession } from './input/scroll-find.js';
|