@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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A regular beat, so SILENCE in the daemon log becomes evidence.
|
|
3
|
+
*
|
|
4
|
+
* `installExitTrace` hooks `'exit'`, which a SIGKILL never fires. So a daemon that is killed —
|
|
5
|
+
* crashed, OOM-ed, swept by another project, `kill -9`'d by the recipe in every troubleshooting
|
|
6
|
+
* thread — leaves no trace at all, and the last line before its port goes dark is whatever it
|
|
7
|
+
* happened to be doing. Worse, a daemon that DID exit logs `code: 0`, so a tidy shutdown and a
|
|
8
|
+
* violent death are the same shape to anyone reading the file.
|
|
9
|
+
*
|
|
10
|
+
* Measured cost of that: a correct SvelteKit install was written up as an install failure, because
|
|
11
|
+
* the daemon had died 21 seconds earlier and nothing in the log said so. The fixture was named in the
|
|
12
|
+
* report; the daemon was not.
|
|
13
|
+
*
|
|
14
|
+
* Two halves, and the second is what makes the first worth having:
|
|
15
|
+
* - DaemonHeartbeat emits `reticle_daemon_alive` on a fixed cadence, unconditionally.
|
|
16
|
+
* - classifyDaemonLife reads a log back and says which way the daemon ended.
|
|
17
|
+
*
|
|
18
|
+
* A heartbeat nobody interprets is just log volume.
|
|
19
|
+
*/
|
|
20
|
+
/** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
|
|
21
|
+
export declare const DAEMON_HEARTBEAT_EVENT = "reticle_daemon_alive";
|
|
22
|
+
/** Written by installExitTrace when the process exits through Node. */
|
|
23
|
+
export declare const DAEMON_EXIT_EVENT = "reticle_daemon_exiting";
|
|
24
|
+
/** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
|
|
25
|
+
export declare const DAEMON_SIGNAL_EVENT = "reticle_daemon_signalled";
|
|
26
|
+
/**
|
|
27
|
+
* How often a live daemon says so.
|
|
28
|
+
*
|
|
29
|
+
* The median daemon lives 28 minutes, so at 30s that is ~56 lines — readable, and small against a log
|
|
30
|
+
* that already rotates. Shorter would make the gap sharper and the file noisier; the gap only needs
|
|
31
|
+
* to be legible against a connect window, which is measured in seconds.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DAEMON_HEARTBEAT_MS = 30000;
|
|
34
|
+
/**
|
|
35
|
+
* The cadence, from the environment. Same shape as `resolveIdleCheckMs`: a bad value falls back
|
|
36
|
+
* rather than throwing, and 0 or negative is not a legal cadence — it would spin.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveHeartbeatMs(raw: string | undefined): number;
|
|
39
|
+
interface HeartbeatFacts {
|
|
40
|
+
/** Browser sessions connected right now. */
|
|
41
|
+
sessions: number;
|
|
42
|
+
/**
|
|
43
|
+
* Has this daemon EVER served a tool call? A lifetime fact, not a window count.
|
|
44
|
+
*
|
|
45
|
+
* It is the difference between a death that cost somebody work and one that cost nothing — the
|
|
46
|
+
* same distinction `isUselessDaemon` already draws. A windowed count would reset under the reader
|
|
47
|
+
* and make a long-lived daemon look freshly idle.
|
|
48
|
+
*/
|
|
49
|
+
served: boolean;
|
|
50
|
+
}
|
|
51
|
+
interface DaemonHeartbeatOptions {
|
|
52
|
+
log: (event: string, fields: Record<string, unknown>) => void;
|
|
53
|
+
/** Read at each beat, never cached — the point is to describe the daemon NOW. */
|
|
54
|
+
facts: () => HeartbeatFacts;
|
|
55
|
+
intervalMs?: number;
|
|
56
|
+
/** Injected, per the repo rule: never call Date.now() inside logic. */
|
|
57
|
+
clock?: () => number;
|
|
58
|
+
}
|
|
59
|
+
export declare class DaemonHeartbeat {
|
|
60
|
+
#private;
|
|
61
|
+
constructor(opts: DaemonHeartbeatOptions);
|
|
62
|
+
/**
|
|
63
|
+
* One beat. Exposed so a test can drive it with an injected clock rather than waiting.
|
|
64
|
+
*
|
|
65
|
+
* Unconditional on purpose. Skipping the beat when nothing happened is the obvious optimisation and
|
|
66
|
+
* it destroys the signal: silence would then mean "idle" OR "dead", which is precisely the
|
|
67
|
+
* ambiguity this exists to remove.
|
|
68
|
+
*/
|
|
69
|
+
beat(): void;
|
|
70
|
+
start(): void;
|
|
71
|
+
stop(): void;
|
|
72
|
+
}
|
|
73
|
+
/** How a daemon's life ended, as far as its own log can say. */
|
|
74
|
+
export declare const DaemonEnd: {
|
|
75
|
+
/** Still beating within tolerance. */
|
|
76
|
+
readonly ALIVE: "alive";
|
|
77
|
+
/** Logged its own exit, with no signal before it. */
|
|
78
|
+
readonly CLEAN: "clean";
|
|
79
|
+
/** A signal arrived — the cause an exit line with `code: 0` does not carry. */
|
|
80
|
+
readonly SIGNALLED: "signalled";
|
|
81
|
+
/** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
|
|
82
|
+
readonly DIED_SILENTLY: "died_silently";
|
|
83
|
+
/** No beat was ever emitted, so this log cannot answer the question. */
|
|
84
|
+
readonly UNKNOWN: "unknown";
|
|
85
|
+
};
|
|
86
|
+
export type DaemonEnd = (typeof DaemonEnd)[keyof typeof DaemonEnd];
|
|
87
|
+
/**
|
|
88
|
+
* One parsed line of a daemon log. The index signature is not laziness: these lines carry whatever
|
|
89
|
+
* their event needed (`code`, `port`, `pid`, `reason`, …), and a reader that refused unknown keys
|
|
90
|
+
* would reject the very file it exists to read.
|
|
91
|
+
*/
|
|
92
|
+
interface LogLine {
|
|
93
|
+
t: number;
|
|
94
|
+
event: string;
|
|
95
|
+
signal?: unknown;
|
|
96
|
+
everyMs?: unknown;
|
|
97
|
+
[field: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
export interface DaemonLife {
|
|
100
|
+
end: DaemonEnd;
|
|
101
|
+
/** When the last beat landed, or undefined if there was never one. */
|
|
102
|
+
lastBeatMs?: number;
|
|
103
|
+
/** How long the log has been silent, when that is the finding. */
|
|
104
|
+
silentForMs?: number;
|
|
105
|
+
signal?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read a daemon log back and say how it ended.
|
|
109
|
+
*
|
|
110
|
+
* UNKNOWN rather than ALIVE when there is no heartbeat at all — every log written before this
|
|
111
|
+
* existed, and any file the reader was pointed at by mistake. Inferring health from the absence of a
|
|
112
|
+
* signal we never sent is the same confident-wrong this module replaces.
|
|
113
|
+
*/
|
|
114
|
+
export declare function classifyDaemonLife(lines: readonly LogLine[], nowMs: number): DaemonLife;
|
|
115
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A regular beat, so SILENCE in the daemon log becomes evidence.
|
|
3
|
+
*
|
|
4
|
+
* `installExitTrace` hooks `'exit'`, which a SIGKILL never fires. So a daemon that is killed —
|
|
5
|
+
* crashed, OOM-ed, swept by another project, `kill -9`'d by the recipe in every troubleshooting
|
|
6
|
+
* thread — leaves no trace at all, and the last line before its port goes dark is whatever it
|
|
7
|
+
* happened to be doing. Worse, a daemon that DID exit logs `code: 0`, so a tidy shutdown and a
|
|
8
|
+
* violent death are the same shape to anyone reading the file.
|
|
9
|
+
*
|
|
10
|
+
* Measured cost of that: a correct SvelteKit install was written up as an install failure, because
|
|
11
|
+
* the daemon had died 21 seconds earlier and nothing in the log said so. The fixture was named in the
|
|
12
|
+
* report; the daemon was not.
|
|
13
|
+
*
|
|
14
|
+
* Two halves, and the second is what makes the first worth having:
|
|
15
|
+
* - DaemonHeartbeat emits `reticle_daemon_alive` on a fixed cadence, unconditionally.
|
|
16
|
+
* - classifyDaemonLife reads a log back and says which way the daemon ended.
|
|
17
|
+
*
|
|
18
|
+
* A heartbeat nobody interprets is just log volume.
|
|
19
|
+
*/
|
|
20
|
+
/** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
|
|
21
|
+
export const DAEMON_HEARTBEAT_EVENT = 'reticle_daemon_alive';
|
|
22
|
+
/** Written by installExitTrace when the process exits through Node. */
|
|
23
|
+
export const DAEMON_EXIT_EVENT = 'reticle_daemon_exiting';
|
|
24
|
+
/** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
|
|
25
|
+
export const DAEMON_SIGNAL_EVENT = 'reticle_daemon_signalled';
|
|
26
|
+
/**
|
|
27
|
+
* How often a live daemon says so.
|
|
28
|
+
*
|
|
29
|
+
* The median daemon lives 28 minutes, so at 30s that is ~56 lines — readable, and small against a log
|
|
30
|
+
* that already rotates. Shorter would make the gap sharper and the file noisier; the gap only needs
|
|
31
|
+
* to be legible against a connect window, which is measured in seconds.
|
|
32
|
+
*/
|
|
33
|
+
export const DAEMON_HEARTBEAT_MS = 30_000;
|
|
34
|
+
/**
|
|
35
|
+
* How many beats may be missed before a reader calls it dead.
|
|
36
|
+
*
|
|
37
|
+
* Timers slip under load — the battery boots three servers and a browser — and a reader that cries
|
|
38
|
+
* death on one late beat is a reader people learn to ignore. Two intervals plus a margin.
|
|
39
|
+
*/
|
|
40
|
+
const MISSED_BEATS_TOLERATED = 2;
|
|
41
|
+
/**
|
|
42
|
+
* The cadence, from the environment. Same shape as `resolveIdleCheckMs`: a bad value falls back
|
|
43
|
+
* rather than throwing, and 0 or negative is not a legal cadence — it would spin.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveHeartbeatMs(raw) {
|
|
46
|
+
if (raw === undefined || '' === raw.trim())
|
|
47
|
+
return DAEMON_HEARTBEAT_MS;
|
|
48
|
+
const n = Number(raw);
|
|
49
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
50
|
+
return DAEMON_HEARTBEAT_MS;
|
|
51
|
+
return Math.floor(n);
|
|
52
|
+
}
|
|
53
|
+
export class DaemonHeartbeat {
|
|
54
|
+
#log;
|
|
55
|
+
#facts;
|
|
56
|
+
#intervalMs;
|
|
57
|
+
#clock;
|
|
58
|
+
#startedAt;
|
|
59
|
+
#timer;
|
|
60
|
+
constructor(opts) {
|
|
61
|
+
this.#log = opts.log;
|
|
62
|
+
this.#facts = opts.facts;
|
|
63
|
+
this.#intervalMs = opts.intervalMs ?? DAEMON_HEARTBEAT_MS;
|
|
64
|
+
this.#clock = opts.clock ?? (() => Date.now());
|
|
65
|
+
this.#startedAt = this.#clock();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One beat. Exposed so a test can drive it with an injected clock rather than waiting.
|
|
69
|
+
*
|
|
70
|
+
* Unconditional on purpose. Skipping the beat when nothing happened is the obvious optimisation and
|
|
71
|
+
* it destroys the signal: silence would then mean "idle" OR "dead", which is precisely the
|
|
72
|
+
* ambiguity this exists to remove.
|
|
73
|
+
*/
|
|
74
|
+
beat() {
|
|
75
|
+
const facts = this.#facts();
|
|
76
|
+
this.#log(DAEMON_HEARTBEAT_EVENT, {
|
|
77
|
+
uptimeMs: this.#clock() - this.#startedAt,
|
|
78
|
+
// The cadence travels WITH the beat so a reader never hard-codes it. Hard-coding is how the
|
|
79
|
+
// reader and the emitter drift apart the first time the interval is tuned.
|
|
80
|
+
everyMs: this.#intervalMs,
|
|
81
|
+
sessions: facts.sessions,
|
|
82
|
+
served: facts.served,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
start() {
|
|
86
|
+
if (this.#timer !== undefined)
|
|
87
|
+
return;
|
|
88
|
+
this.#timer = setInterval(() => this.beat(), this.#intervalMs);
|
|
89
|
+
// Never the reason a process stays alive — this is a diagnostic, not a workload.
|
|
90
|
+
this.#timer.unref();
|
|
91
|
+
}
|
|
92
|
+
stop() {
|
|
93
|
+
if (this.#timer === undefined)
|
|
94
|
+
return;
|
|
95
|
+
clearInterval(this.#timer);
|
|
96
|
+
this.#timer = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** How a daemon's life ended, as far as its own log can say. */
|
|
100
|
+
export const DaemonEnd = {
|
|
101
|
+
/** Still beating within tolerance. */
|
|
102
|
+
ALIVE: 'alive',
|
|
103
|
+
/** Logged its own exit, with no signal before it. */
|
|
104
|
+
CLEAN: 'clean',
|
|
105
|
+
/** A signal arrived — the cause an exit line with `code: 0` does not carry. */
|
|
106
|
+
SIGNALLED: 'signalled',
|
|
107
|
+
/** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
|
|
108
|
+
DIED_SILENTLY: 'died_silently',
|
|
109
|
+
/** No beat was ever emitted, so this log cannot answer the question. */
|
|
110
|
+
UNKNOWN: 'unknown',
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Read a daemon log back and say how it ended.
|
|
114
|
+
*
|
|
115
|
+
* UNKNOWN rather than ALIVE when there is no heartbeat at all — every log written before this
|
|
116
|
+
* existed, and any file the reader was pointed at by mistake. Inferring health from the absence of a
|
|
117
|
+
* signal we never sent is the same confident-wrong this module replaces.
|
|
118
|
+
*/
|
|
119
|
+
export function classifyDaemonLife(lines, nowMs) {
|
|
120
|
+
const beats = lines.filter((l) => l.event === DAEMON_HEARTBEAT_EVENT);
|
|
121
|
+
const exit = lines.findLast((l) => l.event === DAEMON_EXIT_EVENT);
|
|
122
|
+
const signal = lines.findLast((l) => l.event === DAEMON_SIGNAL_EVENT);
|
|
123
|
+
if (exit !== undefined) {
|
|
124
|
+
// A signal is the CAUSE; the exit line only says it happened. Reporting `clean` for a SIGTERMed
|
|
125
|
+
// daemon is how "shut down tidily" and "the bridge every app needs is gone" became one sentence.
|
|
126
|
+
return signal === undefined
|
|
127
|
+
? { end: DaemonEnd.CLEAN }
|
|
128
|
+
: {
|
|
129
|
+
end: DaemonEnd.SIGNALLED,
|
|
130
|
+
...('string' === typeof signal.signal ? { signal: signal.signal } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const last = beats.at(-1);
|
|
134
|
+
if (last === undefined)
|
|
135
|
+
return { end: DaemonEnd.UNKNOWN };
|
|
136
|
+
const everyMs = 'number' === typeof last.everyMs ? last.everyMs : DAEMON_HEARTBEAT_MS;
|
|
137
|
+
const silentForMs = nowMs - last.t;
|
|
138
|
+
return silentForMs > everyMs * MISSED_BEATS_TOLERATED
|
|
139
|
+
? { end: DaemonEnd.DIED_SILENTLY, lastBeatMs: last.t, silentForMs }
|
|
140
|
+
: { end: DaemonEnd.ALIVE, lastBeatMs: last.t };
|
|
141
|
+
}
|
|
@@ -35,12 +35,20 @@ export declare class IdleShutdown {
|
|
|
35
35
|
stop(): void;
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
* `0`
|
|
38
|
+
* The idle re-check cadence, from the environment. Same shape as the grace resolver, with one
|
|
39
|
+
* deliberate difference: `0` is not a legal cadence here (it would spin), so `<= 0` falls back to
|
|
40
|
+
* the default rather than meaning anything.
|
|
40
41
|
*/
|
|
42
|
+
export declare function resolveIdleCheckMs(raw: string | undefined): number;
|
|
41
43
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
+
* Resolve the idle-shutdown grace from `RETICLE_IDLE_SHUTDOWN_MS`: a non-negative integer of
|
|
45
|
+
* milliseconds, `0` to disable. Anything missing or invalid falls back to the default. Pure.
|
|
46
|
+
*
|
|
47
|
+
* The disable is NOT implemented here — 0 is passed through as 0, and `IdleShutdown` treats
|
|
48
|
+
* `graceMs <= 0` as "never self-shut-down" in both `start()` and `check()`. Said out loud because
|
|
49
|
+
* this docblock had drifted onto the function above it, leaving this one undocumented next to a
|
|
50
|
+
* bare `Math.floor`: a reviewer driving 2.5.0 read it as a documented option with no branch
|
|
51
|
+
* implementing it and filed the gate that depends on it as inverted. The behaviour was correct in
|
|
52
|
+
* every build; only the code was readable as broken.
|
|
44
53
|
*/
|
|
45
|
-
export declare function resolveIdleCheckMs(raw: string | undefined): number;
|
|
46
54
|
export declare function resolveIdleShutdownMs(raw: string | undefined): number;
|
|
@@ -65,12 +65,9 @@ export class IdleShutdown {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
* `0`
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The idle re-check cadence, from the environment. Same shape as the grace resolver: a bad value
|
|
73
|
-
* falls back rather than throwing, and 0/negative is not a legal cadence (it would spin).
|
|
68
|
+
* The idle re-check cadence, from the environment. Same shape as the grace resolver, with one
|
|
69
|
+
* deliberate difference: `0` is not a legal cadence here (it would spin), so `<= 0` falls back to
|
|
70
|
+
* the default rather than meaning anything.
|
|
74
71
|
*/
|
|
75
72
|
export function resolveIdleCheckMs(raw) {
|
|
76
73
|
if (raw === undefined || '' === raw.trim())
|
|
@@ -80,6 +77,17 @@ export function resolveIdleCheckMs(raw) {
|
|
|
80
77
|
return SESSION_LIFECYCLE.DAEMON_IDLE_CHECK_MS;
|
|
81
78
|
return Math.floor(n);
|
|
82
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the idle-shutdown grace from `RETICLE_IDLE_SHUTDOWN_MS`: a non-negative integer of
|
|
82
|
+
* milliseconds, `0` to disable. Anything missing or invalid falls back to the default. Pure.
|
|
83
|
+
*
|
|
84
|
+
* The disable is NOT implemented here — 0 is passed through as 0, and `IdleShutdown` treats
|
|
85
|
+
* `graceMs <= 0` as "never self-shut-down" in both `start()` and `check()`. Said out loud because
|
|
86
|
+
* this docblock had drifted onto the function above it, leaving this one undocumented next to a
|
|
87
|
+
* bare `Math.floor`: a reviewer driving 2.5.0 read it as a documented option with no branch
|
|
88
|
+
* implementing it and filed the gate that depends on it as inverted. The behaviour was correct in
|
|
89
|
+
* every build; only the code was readable as broken.
|
|
90
|
+
*/
|
|
83
91
|
export function resolveIdleShutdownMs(raw) {
|
|
84
92
|
if (raw === undefined || '' === raw.trim())
|
|
85
93
|
return SESSION_LIFECYCLE.DAEMON_IDLE_SHUTDOWN_MS;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is actually on the bridge port — three states, because two produced a lie.
|
|
3
|
+
*
|
|
4
|
+
* `isRunning` reads the pid file and never the port. So "a stranger holds 4400" and "nothing holds
|
|
5
|
+
* 4400" both answer `running: false`, and every surface built on it inherits that:
|
|
6
|
+
*
|
|
7
|
+
* $ reticle serve
|
|
8
|
+
* {"event":"reticle_daemon_spawned","port":4400} <- exit 0
|
|
9
|
+
* $ reticle status
|
|
10
|
+
* {"event":"reticle_status","port":4400,"running":false}
|
|
11
|
+
* $ tail -1 ~/.reticle/daemon-4400.log
|
|
12
|
+
* {"event":"reticle_daemon_start_failed","error":"listen EADDRINUSE: ... 127.0.0.1:4400"}
|
|
13
|
+
*
|
|
14
|
+
* The daemon knew exactly what was wrong and wrote it where nobody looks. The true sentence — *port
|
|
15
|
+
* 4400 is held by another process* — was never said by anything a person sees.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately probe-based rather than lsof-based: two questions (does TCP connect, does `/status`
|
|
18
|
+
* answer) separate all three states with no platform-specific code, and they work on Windows, where
|
|
19
|
+
* every `lsof` recipe in this repo does not. Naming the holding process is a nicer message and a
|
|
20
|
+
* strictly harder problem; it is not needed to stop lying.
|
|
21
|
+
*/
|
|
22
|
+
export declare const PortPresence: {
|
|
23
|
+
/** A Reticle daemon answered `/status`. The only usable state. */
|
|
24
|
+
readonly DAEMON: "daemon";
|
|
25
|
+
/**
|
|
26
|
+
* Something accepts connections here and does not serve Reticle's status endpoint.
|
|
27
|
+
*
|
|
28
|
+
* A foreign process, or one of our own daemons wedged mid-start — and those are the same fact for
|
|
29
|
+
* every caller: this port cannot be bound and cannot be used.
|
|
30
|
+
*/
|
|
31
|
+
readonly FOREIGN: "foreign";
|
|
32
|
+
/** Nothing is listening. */
|
|
33
|
+
readonly FREE: "free";
|
|
34
|
+
};
|
|
35
|
+
export type PortPresence = (typeof PortPresence)[keyof typeof PortPresence];
|
|
36
|
+
interface PortFacts {
|
|
37
|
+
/** A TCP connection to the port was accepted. */
|
|
38
|
+
tcpOpen: boolean;
|
|
39
|
+
/** An HTTP GET of `/status` returned a Reticle status payload. */
|
|
40
|
+
statusAnswered: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pure. The probes live at the call sites (`probeDaemon`, `fetchStatus`) so this stays testable
|
|
44
|
+
* without a socket, and so a probe that starts lying is a separate failure from a rule that does.
|
|
45
|
+
*/
|
|
46
|
+
export declare function classifyPort(facts: PortFacts): PortPresence;
|
|
47
|
+
/** True only for a daemon that answered. Anything else must not be treated as "running". */
|
|
48
|
+
export declare function presenceIsUsable(presence: PortPresence): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Ask the port both questions and classify the answers.
|
|
51
|
+
*
|
|
52
|
+
* The TCP probe is first and cheap; `/status` is only asked when something accepted, which keeps the
|
|
53
|
+
* common "nothing there" case to one failed connect. Impure by necessity — the rule above is where
|
|
54
|
+
* the behaviour lives, and it is tested without a socket.
|
|
55
|
+
*/
|
|
56
|
+
export declare function probePresence(port: number, probes: {
|
|
57
|
+
tcpOpen: (port: number) => Promise<boolean>;
|
|
58
|
+
status: (port: number) => Promise<unknown>;
|
|
59
|
+
}): Promise<PortPresence>;
|
|
60
|
+
/**
|
|
61
|
+
* Who is holding the port, when we can tell.
|
|
62
|
+
*
|
|
63
|
+
* `FOREIGN` covers two situations that need the same answer to "can I bind this?" and OPPOSITE
|
|
64
|
+
* answers to "what do I do?": a stranger's process, and one of our own daemons wedged. Passing
|
|
65
|
+
* these lets the sentence tell them apart when — and only when — the recorded pid for the port
|
|
66
|
+
* matches the process actually holding it.
|
|
67
|
+
*/
|
|
68
|
+
export interface PortHolder {
|
|
69
|
+
/** The pid we recorded for this port, from the daemon pid file. `null` when there is none. */
|
|
70
|
+
ourPid: number | null;
|
|
71
|
+
/** The pid actually holding the port, when the caller could determine it. */
|
|
72
|
+
holderPid: number | undefined;
|
|
73
|
+
}
|
|
74
|
+
/** One sentence a human can act on, for each state. Exhaustive by switch, so a new state must add one. */
|
|
75
|
+
export declare function describePresence(presence: PortPresence, port: number, holder?: PortHolder): string;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is actually on the bridge port — three states, because two produced a lie.
|
|
3
|
+
*
|
|
4
|
+
* `isRunning` reads the pid file and never the port. So "a stranger holds 4400" and "nothing holds
|
|
5
|
+
* 4400" both answer `running: false`, and every surface built on it inherits that:
|
|
6
|
+
*
|
|
7
|
+
* $ reticle serve
|
|
8
|
+
* {"event":"reticle_daemon_spawned","port":4400} <- exit 0
|
|
9
|
+
* $ reticle status
|
|
10
|
+
* {"event":"reticle_status","port":4400,"running":false}
|
|
11
|
+
* $ tail -1 ~/.reticle/daemon-4400.log
|
|
12
|
+
* {"event":"reticle_daemon_start_failed","error":"listen EADDRINUSE: ... 127.0.0.1:4400"}
|
|
13
|
+
*
|
|
14
|
+
* The daemon knew exactly what was wrong and wrote it where nobody looks. The true sentence — *port
|
|
15
|
+
* 4400 is held by another process* — was never said by anything a person sees.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately probe-based rather than lsof-based: two questions (does TCP connect, does `/status`
|
|
18
|
+
* answer) separate all three states with no platform-specific code, and they work on Windows, where
|
|
19
|
+
* every `lsof` recipe in this repo does not. Naming the holding process is a nicer message and a
|
|
20
|
+
* strictly harder problem; it is not needed to stop lying.
|
|
21
|
+
*/
|
|
22
|
+
export const PortPresence = {
|
|
23
|
+
/** A Reticle daemon answered `/status`. The only usable state. */
|
|
24
|
+
DAEMON: 'daemon',
|
|
25
|
+
/**
|
|
26
|
+
* Something accepts connections here and does not serve Reticle's status endpoint.
|
|
27
|
+
*
|
|
28
|
+
* A foreign process, or one of our own daemons wedged mid-start — and those are the same fact for
|
|
29
|
+
* every caller: this port cannot be bound and cannot be used.
|
|
30
|
+
*/
|
|
31
|
+
FOREIGN: 'foreign',
|
|
32
|
+
/** Nothing is listening. */
|
|
33
|
+
FREE: 'free',
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Pure. The probes live at the call sites (`probeDaemon`, `fetchStatus`) so this stays testable
|
|
37
|
+
* without a socket, and so a probe that starts lying is a separate failure from a rule that does.
|
|
38
|
+
*/
|
|
39
|
+
export function classifyPort(facts) {
|
|
40
|
+
if (facts.statusAnswered) {
|
|
41
|
+
if (!facts.tcpOpen) {
|
|
42
|
+
// Not reachable from a real pair of probes. Folding it into DAEMON would mean a broken TCP
|
|
43
|
+
// probe reports a healthy daemon — the exact shape of confident-wrong this module exists to
|
|
44
|
+
// remove — so it is an error rather than a guess.
|
|
45
|
+
throw new Error('a port cannot answer /status without accepting a TCP connection; one of the probes is wrong');
|
|
46
|
+
}
|
|
47
|
+
return PortPresence.DAEMON;
|
|
48
|
+
}
|
|
49
|
+
return facts.tcpOpen ? PortPresence.FOREIGN : PortPresence.FREE;
|
|
50
|
+
}
|
|
51
|
+
/** True only for a daemon that answered. Anything else must not be treated as "running". */
|
|
52
|
+
export function presenceIsUsable(presence) {
|
|
53
|
+
return presence === PortPresence.DAEMON;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Ask the port both questions and classify the answers.
|
|
57
|
+
*
|
|
58
|
+
* The TCP probe is first and cheap; `/status` is only asked when something accepted, which keeps the
|
|
59
|
+
* common "nothing there" case to one failed connect. Impure by necessity — the rule above is where
|
|
60
|
+
* the behaviour lives, and it is tested without a socket.
|
|
61
|
+
*/
|
|
62
|
+
export async function probePresence(port, probes) {
|
|
63
|
+
const tcpOpen = await probes.tcpOpen(port);
|
|
64
|
+
if (!tcpOpen)
|
|
65
|
+
return PortPresence.FREE;
|
|
66
|
+
const status = await probes.status(port);
|
|
67
|
+
return classifyPort({ tcpOpen, statusAnswered: status !== undefined });
|
|
68
|
+
}
|
|
69
|
+
/** One sentence a human can act on, for each state. Exhaustive by switch, so a new state must add one. */
|
|
70
|
+
export function describePresence(presence, port, holder) {
|
|
71
|
+
switch (presence) {
|
|
72
|
+
case PortPresence.DAEMON:
|
|
73
|
+
return `a Reticle daemon is serving :${String(port)}`;
|
|
74
|
+
case PortPresence.FOREIGN:
|
|
75
|
+
// Our own daemon, wedged: it accepts TCP and never answers `/status`, so it classifies as
|
|
76
|
+
// FOREIGN — correct for binding, and a lie in prose. Found by SIGSTOPping a daemon: doctor
|
|
77
|
+
// said "which is not a Reticle daemon" about a pid its own pid file named, and sent the
|
|
78
|
+
// reader hunting a stranger while never mentioning the fix that works.
|
|
79
|
+
if (holder?.ourPid !== null &&
|
|
80
|
+
holder?.ourPid !== undefined &&
|
|
81
|
+
holder.ourPid === holder.holderPid) {
|
|
82
|
+
return (`port ${String(port)} is held by YOUR Reticle daemon (pid ${String(holder.ourPid)}), ` +
|
|
83
|
+
'and it is not responding — it is running but wedged, so nothing can use it and nothing ' +
|
|
84
|
+
'else can bind the port. Stop it (`reticle stop`) and the next tool call starts a fresh ' +
|
|
85
|
+
'one.');
|
|
86
|
+
}
|
|
87
|
+
return (`port ${String(port)} is held by another process that is not a Reticle daemon — ` +
|
|
88
|
+
'a daemon cannot bind it. Stop that process, or run Reticle on a different port ' +
|
|
89
|
+
'(`--port`), then retry.');
|
|
90
|
+
case PortPresence.FREE:
|
|
91
|
+
return `nothing is listening on :${String(port)} — no daemon is running`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sentence naming the problem, or `undefined` when the directory is usable.
|
|
3
|
+
*
|
|
4
|
+
* A directory that does not exist yet is NOT a problem: the daemon creates it, and the creation
|
|
5
|
+
* failing is caught by the same probe on the parent path.
|
|
6
|
+
*/
|
|
7
|
+
export declare function stateDirProblem(dir: string): string | undefined;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can Reticle write the directory it keeps everything in?
|
|
3
|
+
*
|
|
4
|
+
* `~/.reticle` holds the pid files, the daemon logs, the telemetry id and outbox, and every saved
|
|
5
|
+
* flow. A daemon that cannot write it does not start — and until now said nothing useful about why.
|
|
6
|
+
*
|
|
7
|
+
* Measured on 2.6.0 with `chmod 555 ~/.reticle`:
|
|
8
|
+
*
|
|
9
|
+
* the daemon did not come up on :4415 — nothing is listening on :4415 — no daemon is running
|
|
10
|
+
* see /…/.reticle/daemon-4415.log
|
|
11
|
+
*
|
|
12
|
+
* The first line restates the symptom. The second points at a log **inside the unwritable
|
|
13
|
+
* directory**, so the one diagnostic offered cannot exist. Someone hitting this — a locked-down home
|
|
14
|
+
* directory, a container with a read-only mount, a managed corporate profile — has nothing to go on.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately a PROBE, not a permission-bit read: `stat` says what the mode claims, and an ACL, a
|
|
17
|
+
* read-only mount, SELinux or a full disk all produce a writable-looking mode and a failing write.
|
|
18
|
+
* The only honest test of "can I write here" is writing.
|
|
19
|
+
*/
|
|
20
|
+
import { mkdirSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
/** Probe filename. Distinctive so a leftover is obviously ours, and unlinked immediately either way. */
|
|
23
|
+
const PROBE = '.reticle-write-probe';
|
|
24
|
+
/**
|
|
25
|
+
* A sentence naming the problem, or `undefined` when the directory is usable.
|
|
26
|
+
*
|
|
27
|
+
* A directory that does not exist yet is NOT a problem: the daemon creates it, and the creation
|
|
28
|
+
* failing is caught by the same probe on the parent path.
|
|
29
|
+
*/
|
|
30
|
+
export function stateDirProblem(dir) {
|
|
31
|
+
if (!existsSync(dir)) {
|
|
32
|
+
// Not yet created is the ordinary first-run state. Only report if we cannot create it.
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return (`Reticle cannot create its state directory at ${dir}. Everything it keeps — the daemon ` +
|
|
38
|
+
'log, the pid file, saved flows — lives there, so the daemon cannot start. Check the ' +
|
|
39
|
+
'permissions on the parent directory, or point Reticle elsewhere with HOME.');
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const probe = join(dir, PROBE);
|
|
44
|
+
try {
|
|
45
|
+
writeFileSync(probe, '', 'utf8');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return (`Reticle cannot write to its state directory at ${dir}. Everything it keeps — the daemon ` +
|
|
49
|
+
'log, the pid file, saved flows — lives there, so the daemon cannot start. Make it writable ' +
|
|
50
|
+
'(`chmod u+w`), or point Reticle elsewhere with HOME.');
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(probe);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* wrote but could not clean up: the directory IS writable, which is the question asked */
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pid of a daemon that died without exiting, observed at the next start.
|
|
3
|
+
*
|
|
4
|
+
* `DaemonExitReason` covers every departure that goes through Node — signal, idle, and the
|
|
5
|
+
* load-bearing `unknown` for a stray exit or an uncaught throw. It cannot cover SIGKILL, an OOM
|
|
6
|
+
* kill, or a lost machine: no handler runs, so the log's last word about that daemon is whatever it
|
|
7
|
+
* happened to be doing. From the report (#123):
|
|
8
|
+
*
|
|
9
|
+
* 10:49:05 mcp_daemon_started port:4400 <- pid 2884
|
|
10
|
+
* 10:51:29 mcp_daemon_started port:4400 pid:4900 <- a SECOND daemon binds the SAME port
|
|
11
|
+
*
|
|
12
|
+
* pid 2884 died with no exit event at all, and the only evidence was that a second daemon could bind
|
|
13
|
+
* a port the first one held — something a reader has to notice and interpret.
|
|
14
|
+
*
|
|
15
|
+
* The next startup is the only place that death is observable, because it is the only moment
|
|
16
|
+
* anything looks at the pidfile the dead process left behind. A tidy shutdown unlinks its own, so
|
|
17
|
+
* finding one whose owner is gone IS the signal.
|
|
18
|
+
*
|
|
19
|
+
* Pure, and deliberately conservative: an owner that is still ALIVE is a port conflict, not a death,
|
|
20
|
+
* and accusing a live sibling of dying would be a new false claim in the log this exists to make
|
|
21
|
+
* honest.
|
|
22
|
+
*/
|
|
23
|
+
export declare function uncleanPredecessor(owner: number | null, alive: boolean, self: number): number | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pid of a daemon that died without exiting, observed at the next start.
|
|
3
|
+
*
|
|
4
|
+
* `DaemonExitReason` covers every departure that goes through Node — signal, idle, and the
|
|
5
|
+
* load-bearing `unknown` for a stray exit or an uncaught throw. It cannot cover SIGKILL, an OOM
|
|
6
|
+
* kill, or a lost machine: no handler runs, so the log's last word about that daemon is whatever it
|
|
7
|
+
* happened to be doing. From the report (#123):
|
|
8
|
+
*
|
|
9
|
+
* 10:49:05 mcp_daemon_started port:4400 <- pid 2884
|
|
10
|
+
* 10:51:29 mcp_daemon_started port:4400 pid:4900 <- a SECOND daemon binds the SAME port
|
|
11
|
+
*
|
|
12
|
+
* pid 2884 died with no exit event at all, and the only evidence was that a second daemon could bind
|
|
13
|
+
* a port the first one held — something a reader has to notice and interpret.
|
|
14
|
+
*
|
|
15
|
+
* The next startup is the only place that death is observable, because it is the only moment
|
|
16
|
+
* anything looks at the pidfile the dead process left behind. A tidy shutdown unlinks its own, so
|
|
17
|
+
* finding one whose owner is gone IS the signal.
|
|
18
|
+
*
|
|
19
|
+
* Pure, and deliberately conservative: an owner that is still ALIVE is a port conflict, not a death,
|
|
20
|
+
* and accusing a live sibling of dying would be a new false claim in the log this exists to make
|
|
21
|
+
* honest.
|
|
22
|
+
*/
|
|
23
|
+
export function uncleanPredecessor(owner, alive, self) {
|
|
24
|
+
if (null === owner || owner === self || alive)
|
|
25
|
+
return null;
|
|
26
|
+
return owner;
|
|
27
|
+
}
|
|
@@ -83,6 +83,16 @@ export interface EvalResult {
|
|
|
83
83
|
inconclusive?: string;
|
|
84
84
|
evidence?: unknown;
|
|
85
85
|
failureReason?: string;
|
|
86
|
+
/**
|
|
87
|
+
* The wait ended because the TAB went away, not because the app did anything observable.
|
|
88
|
+
*
|
|
89
|
+
* A sibling of `inconclusive` and for the same reason: `pass` is false because nothing was
|
|
90
|
+
* proven, but nobody could have made it true, so grading it as a defect in the user's app is a
|
|
91
|
+
* false claim. Without this the verdict rule saw only `pass: false` and answered
|
|
92
|
+
* `assertion_failed` — "the declared consequence did not hold" — naming the component the agent
|
|
93
|
+
* had just clicked. See VerifiedReason.OBSERVATION_LOST.
|
|
94
|
+
*/
|
|
95
|
+
observationLost?: boolean;
|
|
86
96
|
/**
|
|
87
97
|
* The failure, structured — what was seen, what was required, and which oracle judged it.
|
|
88
98
|
*
|