@reticlehq/server 2.5.0 → 2.7.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 +226 -26
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +59 -6
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/chromium-hint.d.ts +40 -0
- package/dist/cli/chromium-hint.js +75 -0
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +74 -14
- package/dist/cli/cli-launch.d.ts +1 -0
- package/dist/cli/cli-launch.js +4 -1
- 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/cli-port.js +20 -1
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +44 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +170 -19
- 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/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- 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 +17 -3
- package/dist/events/predicate-eval.js +183 -96
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +68 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +35 -17
- 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/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/verified.d.ts +10 -1
- package/dist/honesty/verified.js +42 -3
- package/dist/http-server.js +20 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +36 -3
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +28 -5
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +14 -0
- package/dist/init/detect.js +23 -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/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +60 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +55 -3
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +93 -12
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +169 -23
- package/dist/init/snippets.d.ts +20 -1
- package/dist/init/snippets.js +71 -6
- 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 +39 -9
- package/dist/mcp/mcp.js +64 -2
- package/dist/mcp/proxy-lifecycle.d.ts +19 -0
- package/dist/mcp/proxy-lifecycle.js +21 -0
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +11 -4
- package/dist/session/no-session-diagnosis.js +95 -14
- 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/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +12 -0
- package/dist/session/session-manager.js +83 -3
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +20 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -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 +32 -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 +21 -1
- package/dist/telemetry/telemetry.js +34 -9
- package/dist/telemetry/verification-of.js +26 -1
- package/dist/tools/act-tools.js +63 -14
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +22 -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 +89 -8
- 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 +76 -7
- 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 +60 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +13 -0
- package/dist/tools/tools.js +22 -0
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +15 -3
- 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 +15 -3
- package/docs/telemetry.md +16 -1
- package/docs/token-efficiency.md +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is holding the bridge port.
|
|
3
|
+
*
|
|
4
|
+
* `doctor` is what a user runs when the agent cannot reach the bridge, and the commonest real cause
|
|
5
|
+
* is that something else is on 4400. It already stopped saying "nothing is listening" for that case,
|
|
6
|
+
* but it did not say WHICH process — so the reader's next move was a shell command they had to know.
|
|
7
|
+
*
|
|
8
|
+
* Naming the holder is also the safe answer to a trap this project has hit for real: the obvious
|
|
9
|
+
* lookup, `lsof -ti tcp:4400 | xargs kill -9`, ALSO kills the agent's own `reticle mcp` proxy,
|
|
10
|
+
* because that proxy holds a client connection to the same port and `-ti` does not filter to
|
|
11
|
+
* listeners. Every invocation here is `-sTCP:LISTEN`, and the message never suggests `-ti`.
|
|
12
|
+
*
|
|
13
|
+
* The parser is pure and the lookup takes an injected exec, so the rule is tested on every platform
|
|
14
|
+
* while the shelling out stays at the call site.
|
|
15
|
+
*/
|
|
16
|
+
/** `lsof -F pc` emits one field per line, prefixed by its selector: `p<pid>`, `c<command>`. */
|
|
17
|
+
const PID_FIELD = 'p';
|
|
18
|
+
const COMMAND_FIELD = 'c';
|
|
19
|
+
/**
|
|
20
|
+
* The FIRST listener in `lsof -nP -iTCP:<port> -sTCP:LISTEN -F pc` output, or null.
|
|
21
|
+
*
|
|
22
|
+
* First, not "the only one": a dual-stack listener (IPv4 + IPv6) is reported twice, and on a port
|
|
23
|
+
* conflict the answer the reader needs is "something is there, here is what" rather than an
|
|
24
|
+
* exhaustive list. A second `p` record ends the first.
|
|
25
|
+
*
|
|
26
|
+
* Null on anything incomplete. Half an answer — a pid with no command — prints worse than the
|
|
27
|
+
* un-named message it would replace, which is the same mistake as the port lie this command fixed.
|
|
28
|
+
*/
|
|
29
|
+
export function parsePortHolder(stdout) {
|
|
30
|
+
let pid;
|
|
31
|
+
let command;
|
|
32
|
+
for (const raw of stdout.split('\n')) {
|
|
33
|
+
const line = raw.trim();
|
|
34
|
+
if (0 === line.length)
|
|
35
|
+
continue;
|
|
36
|
+
const value = line.slice(1);
|
|
37
|
+
if (line.startsWith(PID_FIELD)) {
|
|
38
|
+
if (pid !== undefined)
|
|
39
|
+
break; // a second record: the first one is the answer
|
|
40
|
+
const parsed = Number(value);
|
|
41
|
+
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
42
|
+
return null;
|
|
43
|
+
pid = parsed;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (line.startsWith(COMMAND_FIELD) && command === undefined)
|
|
47
|
+
command = value;
|
|
48
|
+
}
|
|
49
|
+
return pid === undefined || command === undefined ? null : { pid, command };
|
|
50
|
+
}
|
|
51
|
+
/** What `doctor` prints for a port held by something that is not a Reticle daemon. */
|
|
52
|
+
export function describeForeignHolder(port, holder,
|
|
53
|
+
/**
|
|
54
|
+
* The pid we recorded for this port, from the daemon pid file.
|
|
55
|
+
*
|
|
56
|
+
* When it matches the process actually holding the port, the port is held by OUR daemon — one
|
|
57
|
+
* that accepts TCP and never answers `/status`, i.e. wedged. That classifies as FOREIGN, which is
|
|
58
|
+
* right for "can anything bind this?" and wrong to say out loud: the reader goes hunting a
|
|
59
|
+
* stranger, and the fix that works (`reticle stop`, it respawns on the next call) is never named.
|
|
60
|
+
*/
|
|
61
|
+
ourPid) {
|
|
62
|
+
const tail = 'a daemon cannot bind it. Stop that process, or run Reticle on a different port ' +
|
|
63
|
+
'(`--port`), then retry.';
|
|
64
|
+
if (null === holder) {
|
|
65
|
+
/**
|
|
66
|
+
* We could not identify the holder — no `lsof` (every Windows user, and 35% of them by the
|
|
67
|
+
* 2026-08 telemetry; also a slim container), or the lookup timed out.
|
|
68
|
+
*
|
|
69
|
+
* This used to assert the port was held by "another process that is not a Reticle daemon",
|
|
70
|
+
* which is a claim the null case has NO evidence for. It is also the likeliest thing to be
|
|
71
|
+
* wrong: reaching here at all means presence is FOREIGN — the port accepts TCP and never
|
|
72
|
+
* answers `/status` — and the commonest cause of that is OUR OWN daemon, wedged. So on the one
|
|
73
|
+
* platform where the lookup cannot run, `doctor` sent the reader hunting a stranger and never
|
|
74
|
+
* named `reticle stop`, the fix that actually works.
|
|
75
|
+
*
|
|
76
|
+
* Say what is known, and name the recorded pid when there is one.
|
|
77
|
+
*/
|
|
78
|
+
const ours = ourPid !== null && ourPid !== undefined
|
|
79
|
+
? ` Reticle's own records show pid ${String(ourPid)} for this port — if that process is ` +
|
|
80
|
+
'still around it is a wedged Reticle daemon, and `reticle stop` clears it.'
|
|
81
|
+
: '';
|
|
82
|
+
return `port ${String(port)} is held by a process Reticle could not identify — ${tail}${ours}`;
|
|
83
|
+
}
|
|
84
|
+
if (ourPid !== null && ourPid !== undefined && ourPid === holder.pid) {
|
|
85
|
+
return (`port ${String(port)} is held by YOUR Reticle daemon (pid ${String(holder.pid)}), and it is ` +
|
|
86
|
+
'not responding — running but wedged, so nothing can use it and nothing else can bind the ' +
|
|
87
|
+
'port. Stop it with `reticle stop`; the next tool call starts a fresh one.');
|
|
88
|
+
}
|
|
89
|
+
return (`port ${String(port)} is held by pid ${String(holder.pid)} ("${holder.command}"), which is not ` +
|
|
90
|
+
`a Reticle daemon — ${tail}`);
|
|
91
|
+
}
|
|
92
|
+
/** The lookup itself. `exec` is injected so the shelling out never runs in a unit test. */
|
|
93
|
+
export function findPortHolder(port, exec) {
|
|
94
|
+
// -n/-P skip DNS and service-name lookups (slow, and irrelevant here). -sTCP:LISTEN is the part
|
|
95
|
+
// that matters: without it this also reports the agent's own MCP proxy, which is a client of the
|
|
96
|
+
// port, not its owner.
|
|
97
|
+
const out = exec('lsof', ['-nP', `-iTCP:${String(port)}`, '-sTCP:LISTEN', '-F', 'pc']);
|
|
98
|
+
return null === out ? null : parsePortHolder(out);
|
|
99
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { realpathSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { stateDirProblem } from './daemon/state-dir.js';
|
|
6
|
+
import { reticleStateHome } from './daemon/daemon.js';
|
|
5
7
|
import { handleWatch, handleCapsules, handleGate, loadNamedFlows, resolveChangedFiles, } from './cli/cli-flow-commands.js';
|
|
6
8
|
import { RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv } from '@reticlehq/core';
|
|
7
9
|
import { loadDotEnv } from './telemetry/dev-repo.js';
|
|
@@ -13,10 +15,13 @@ import { start, startDaemon } from './index.js';
|
|
|
13
15
|
import { isCloudCommand, runCloudCommand } from './cli/cloud-cli.js';
|
|
14
16
|
import { SERVER_VERSION } from './version/server-version.js';
|
|
15
17
|
import { log } from './log.js';
|
|
16
|
-
import { readPid, isAlive,
|
|
18
|
+
import { readPid, isAlive, removePid, spawnDaemon, discoverDaemonPort, writeDaemonRegistry, logPath, } from './daemon/daemon.js';
|
|
19
|
+
import { PortPresence, probePresence, presenceIsUsable, describePresence, } from './daemon/port-presence.js';
|
|
17
20
|
import { waitForDaemon, startMcpProxy, probeDaemon, proxyLog, setProxyLogPort, } from './mcp/mcp-proxy.js';
|
|
18
|
-
import { installDaemonResilience, installProxyResilience } from './daemon/daemon-resilience.js';
|
|
21
|
+
import { installDaemonResilience, installProxyResilience, recordExitReason, DaemonExitReason, } from './daemon/daemon-resilience.js';
|
|
19
22
|
import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
|
|
23
|
+
import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
|
|
24
|
+
import { everServedToolCall } from './daemon/daemon-usefulness.js';
|
|
20
25
|
import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, } from './cli/cli-launch.js';
|
|
21
26
|
import { handleVerify } from './cli/cli-verify.js';
|
|
22
27
|
import { summarizeHunt } from './hunt/hunt-report.js';
|
|
@@ -45,10 +50,38 @@ function handleInit(parsed) {
|
|
|
45
50
|
process.exit(1);
|
|
46
51
|
}
|
|
47
52
|
function handleServe(parsed) {
|
|
48
|
-
|
|
53
|
+
void serveWithHonestExit(parsed);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Report the BIND, not the spawn.
|
|
57
|
+
*
|
|
58
|
+
* `serve` used to log `reticle_daemon_spawned` and exit 0 unconditionally, while the child died at
|
|
59
|
+
* `reticle_daemon_start_failed` with an EADDRINUSE nothing joined back to the parent. Three surfaces
|
|
60
|
+
* then disagreed about what was true and none of them named the port.
|
|
61
|
+
*
|
|
62
|
+
* Two changes, both about saying what is actually the case: refuse up front when the port is held by
|
|
63
|
+
* something that is not a Reticle daemon (a spawn there cannot succeed), and after spawning, wait
|
|
64
|
+
* for the daemon to actually answer before claiming it started.
|
|
65
|
+
*/
|
|
66
|
+
async function serveWithHonestExit(parsed) {
|
|
67
|
+
const presence = await probePresence(parsed.port, {
|
|
68
|
+
tcpOpen: probeDaemon,
|
|
69
|
+
status: fetchStatus,
|
|
70
|
+
});
|
|
71
|
+
if (presence === PortPresence.DAEMON) {
|
|
49
72
|
log('reticle_daemon_already_running', { port: parsed.port });
|
|
50
73
|
return;
|
|
51
74
|
}
|
|
75
|
+
if (presence === PortPresence.FOREIGN) {
|
|
76
|
+
log('reticle_daemon_start_refused', {
|
|
77
|
+
port: parsed.port,
|
|
78
|
+
presence,
|
|
79
|
+
reason: describePresence(presence, parsed.port),
|
|
80
|
+
});
|
|
81
|
+
process.stderr.write(`${describePresence(presence, parsed.port)}\n`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
52
85
|
const scriptPath = process.argv[1];
|
|
53
86
|
if (scriptPath === undefined) {
|
|
54
87
|
log('reticle_serve_no_script', {});
|
|
@@ -69,8 +102,51 @@ function handleServe(parsed) {
|
|
|
69
102
|
daemonArgs.push(HTTP_TOKEN_FLAG, parsed.httpToken);
|
|
70
103
|
}
|
|
71
104
|
spawnDaemon(process.execPath, scriptPath, daemonArgs, parsed.port);
|
|
105
|
+
// The child binds asynchronously and, when it cannot, exits 1 long after this process would have
|
|
106
|
+
// reported success. Wait for it to ANSWER — `/status` responding is the only evidence a daemon
|
|
107
|
+
// exists that does not come from the pid file the child may never have earned.
|
|
108
|
+
const bound = await waitForPresence(parsed.port, PortPresence.DAEMON, SERVE_BIND_TIMEOUT_MS);
|
|
109
|
+
if (!bound) {
|
|
110
|
+
const settled = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
111
|
+
log('reticle_daemon_start_failed_parent', {
|
|
112
|
+
port: parsed.port,
|
|
113
|
+
presence: settled,
|
|
114
|
+
reason: describePresence(settled, parsed.port),
|
|
115
|
+
log: logPath(parsed.port),
|
|
116
|
+
stateDir: stateDirProblem(reticleStateHome()) === undefined ? 'writable' : 'unwritable',
|
|
117
|
+
});
|
|
118
|
+
// Name the CAUSE when we can see it. An unwritable state directory is a first-run failure mode
|
|
119
|
+
// (locked-down home, read-only container mount, managed profile) that produced only "nothing is
|
|
120
|
+
// listening" — the symptom restated — and then pointed at a log INSIDE that directory, which
|
|
121
|
+
// cannot exist. Found by stress-testing 2.6.0 with `chmod 555 ~/.reticle`.
|
|
122
|
+
const dirProblem = stateDirProblem(reticleStateHome());
|
|
123
|
+
process.stderr.write(dirProblem === undefined
|
|
124
|
+
? `the daemon did not come up on :${String(parsed.port)} — ${describePresence(settled, parsed.port)}\n` +
|
|
125
|
+
`see ${logPath(parsed.port)}\n`
|
|
126
|
+
: `the daemon did not come up on :${String(parsed.port)}.\n${dirProblem}\n`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
72
130
|
log('reticle_daemon_spawned', { port: parsed.port, ...(parsed.http ? { http: true } : {}) });
|
|
73
131
|
}
|
|
132
|
+
/** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
|
|
133
|
+
const SERVE_BIND_TIMEOUT_MS = 15_000;
|
|
134
|
+
const PRESENCE_POLL_MS = 150;
|
|
135
|
+
/** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
|
|
136
|
+
const GRACEFUL_STOP_MS = 5000;
|
|
137
|
+
/** How long after SIGKILL before we accept the process is not ours to kill and say so. */
|
|
138
|
+
const FORCED_STOP_MS = 2000;
|
|
139
|
+
async function waitForPresence(port, want, timeoutMs) {
|
|
140
|
+
const deadline = Date.now() + timeoutMs;
|
|
141
|
+
for (;;) {
|
|
142
|
+
const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
143
|
+
if (presence === want)
|
|
144
|
+
return true;
|
|
145
|
+
if (Date.now() >= deadline)
|
|
146
|
+
return false;
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, PRESENCE_POLL_MS));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
74
150
|
function handleStop(port, quiet) {
|
|
75
151
|
const pid = readPid(port);
|
|
76
152
|
if (null === pid || !isAlive(pid)) {
|
|
@@ -81,18 +157,35 @@ function handleStop(port, quiet) {
|
|
|
81
157
|
}
|
|
82
158
|
process.kill(pid, 'SIGTERM');
|
|
83
159
|
const started = Date.now();
|
|
160
|
+
let escalated = false;
|
|
84
161
|
const poll = setInterval(() => {
|
|
85
162
|
if (!isAlive(pid)) {
|
|
86
163
|
clearInterval(poll);
|
|
87
164
|
removePid(port);
|
|
88
165
|
if (!quiet)
|
|
89
|
-
log('reticle_daemon_stopped', { port, pid });
|
|
166
|
+
log('reticle_daemon_stopped', { port, pid, ...(escalated ? { escalated } : {}) });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const waited = Date.now() - started;
|
|
170
|
+
// A daemon that ignores SIGTERM is WEDGED — which is the one situation `stop` exists for. Giving
|
|
171
|
+
// up here left the port held, the pid file stale, and a human running `kill -9` by hand at the
|
|
172
|
+
// first two minutes of setup. Escalate instead: graceful first, then unconditional.
|
|
173
|
+
if (!escalated && waited > GRACEFUL_STOP_MS) {
|
|
174
|
+
escalated = true;
|
|
175
|
+
try {
|
|
176
|
+
process.kill(pid, 'SIGKILL');
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Already gone between the liveness check and here — the next tick reports it stopped.
|
|
180
|
+
}
|
|
90
181
|
return;
|
|
91
182
|
}
|
|
92
|
-
if (
|
|
183
|
+
if (waited > GRACEFUL_STOP_MS + FORCED_STOP_MS) {
|
|
93
184
|
clearInterval(poll);
|
|
185
|
+
// Surviving SIGKILL means the pid is not ours to kill (permissions) or is an unkillable
|
|
186
|
+
// zombie. Neither is retryable, and both need the pid named so a human can act on it.
|
|
94
187
|
if (!quiet)
|
|
95
|
-
log('reticle_daemon_stop_timeout', { port, pid });
|
|
188
|
+
log('reticle_daemon_stop_timeout', { port, pid, escalated });
|
|
96
189
|
process.exit(1);
|
|
97
190
|
}
|
|
98
191
|
}, 100);
|
|
@@ -100,7 +193,16 @@ function handleStop(port, quiet) {
|
|
|
100
193
|
function handleStatus(port) {
|
|
101
194
|
const pid = readPid(port);
|
|
102
195
|
if (null === pid || !isAlive(pid)) {
|
|
103
|
-
|
|
196
|
+
// `running: false` on its own has been reported about a port that was demonstrably occupied,
|
|
197
|
+
// because the pid file is not the port. Ask the port before answering.
|
|
198
|
+
void probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).then((presence) => {
|
|
199
|
+
log('reticle_status', {
|
|
200
|
+
port,
|
|
201
|
+
running: presenceIsUsable(presence),
|
|
202
|
+
presence,
|
|
203
|
+
...(presence === PortPresence.FOREIGN ? { reason: describePresence(presence, port) } : {}),
|
|
204
|
+
});
|
|
205
|
+
});
|
|
104
206
|
return;
|
|
105
207
|
}
|
|
106
208
|
// The daemon is up — ask it for live sessions + health so status is at-a-glance, not just a pid.
|
|
@@ -279,11 +381,21 @@ function handleOpen(requestedPort, url) {
|
|
|
279
381
|
...(connected
|
|
280
382
|
? {}
|
|
281
383
|
: {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
384
|
+
// Two claims used to live here that this command cannot support. It said the browser
|
|
385
|
+
// "was launched" — it asked the OS to open a URL and cannot see whether a window
|
|
386
|
+
// appeared — and it offered "the app may still be loading" as a cause with equal
|
|
387
|
+
// weight to the real one. It then pointed at `reticle doctor`, whose Chromium check is
|
|
388
|
+
// about a browser THIS command never uses, so a missing Chromium reads as the
|
|
389
|
+
// explanation for a session that is missing for an unrelated reason. Both misdirects
|
|
390
|
+
// were reported from the field, each costing several calls of app-side wiring hunt.
|
|
391
|
+
note: `the URL was handed to the system default browser (this command does not use ` +
|
|
392
|
+
`Reticle's own Chromium, so a chromium warning from \`reticle doctor\` is unrelated ` +
|
|
393
|
+
`to this). No Reticle session appeared. By far the likeliest cause is that the app ` +
|
|
394
|
+
`carries no Reticle SDK, or dials a port other than ${String(port)} — run ` +
|
|
395
|
+
`\`reticle init\` in the app's directory and restart its dev server. If the app IS ` +
|
|
396
|
+
`wired, give the page a moment and check the browser console: the SDK announces its ` +
|
|
397
|
+
`own connect failures there, including the one it refuses to make from a ` +
|
|
398
|
+
`non-localhost host without allowNonLocalhost.`,
|
|
287
399
|
}),
|
|
288
400
|
});
|
|
289
401
|
})
|
|
@@ -327,12 +439,20 @@ function handleDaemonInner(parsed) {
|
|
|
327
439
|
removePid(parsed.port);
|
|
328
440
|
process.exit(1);
|
|
329
441
|
});
|
|
330
|
-
const shutdown = () => {
|
|
442
|
+
const shutdown = (reason) => {
|
|
443
|
+
// Recorded BEFORE the async chain: `installExitTrace` reads it when Node is on its way out,
|
|
444
|
+
// which is after everything below has run. Without it the exit line says `code: 0` and a
|
|
445
|
+
// reader cannot tell a tidy stop from the bridge disappearing — see #123.
|
|
446
|
+
recordExitReason(reason);
|
|
331
447
|
// Awaited before the close/exit chain: `process.exit(0)` kills an in-flight POST, and this is
|
|
332
448
|
// the one event carrying the whole session. A failed send resolves anyway (emit swallows its
|
|
333
449
|
// own errors), so this can delay the exit by at most the send timeout, never prevent it.
|
|
334
450
|
void daemonTelemetry
|
|
335
|
-
|
|
451
|
+
// The reason rides out on the session summary, which is the only event that fires at this
|
|
452
|
+
// exact moment. The proxy emits the matching `mcp_connection_lost` and cannot know it —
|
|
453
|
+
// it sees a socket end and nothing more, which is how a scheduled idle exit came to make up
|
|
454
|
+
// the large majority of "outages". See SessionSummary.exit.
|
|
455
|
+
.shutdown(reason)
|
|
336
456
|
.then(() => server.close())
|
|
337
457
|
.then(() => {
|
|
338
458
|
removePid(parsed.port);
|
|
@@ -345,8 +465,10 @@ function handleDaemonInner(parsed) {
|
|
|
345
465
|
process.exit(1);
|
|
346
466
|
});
|
|
347
467
|
};
|
|
348
|
-
|
|
349
|
-
|
|
468
|
+
// Wrapped rather than passed directly: a signal handler receives the signal name as its first
|
|
469
|
+
// argument, which would otherwise arrive as the reason.
|
|
470
|
+
process.on('SIGTERM', () => shutdown(DaemonExitReason.SIGNAL));
|
|
471
|
+
process.on('SIGINT', () => shutdown(DaemonExitReason.SIGNAL));
|
|
350
472
|
// Self-shut-down when idle so a detached daemon (and any headless Chromium it launched) never
|
|
351
473
|
// lingers on the user's machine after the editor closes. Reuses the same clean shutdown path.
|
|
352
474
|
const attachedGraceEnv = process.env[ReticleEnv.IDLE_ATTACHED];
|
|
@@ -363,10 +485,23 @@ function handleDaemonInner(parsed) {
|
|
|
363
485
|
: { attachedGraceMs: resolveIdleShutdownMs(attachedGraceEnv) }),
|
|
364
486
|
onShutdown: () => {
|
|
365
487
|
log('reticle_daemon_idle_exit', { port: parsed.port });
|
|
366
|
-
shutdown();
|
|
488
|
+
shutdown(DaemonExitReason.IDLE);
|
|
367
489
|
},
|
|
368
490
|
});
|
|
369
491
|
idleShutdown.start();
|
|
492
|
+
// Say so, regularly, so a GAP in this log is itself evidence. `installExitTrace` hooks
|
|
493
|
+
// `'exit'`, which a SIGKILL never fires — so a killed daemon left nothing behind, and the one
|
|
494
|
+
// that exited tidily logged `code: 0`. A reader could not tell "shut down cleanly" from "the
|
|
495
|
+
// bridge every app on this machine needs is gone", and a correct SvelteKit install was written
|
|
496
|
+
// up as an install failure on exactly that ambiguity. See daemon/heartbeat.ts.
|
|
497
|
+
new DaemonHeartbeat({
|
|
498
|
+
log,
|
|
499
|
+
intervalMs: resolveHeartbeatMs(process.env[ReticleEnv.HEARTBEAT]),
|
|
500
|
+
facts: () => ({
|
|
501
|
+
sessions: server.bridge.sessions.count(),
|
|
502
|
+
served: everServedToolCall(),
|
|
503
|
+
}),
|
|
504
|
+
}).start();
|
|
370
505
|
})
|
|
371
506
|
.catch((error) => {
|
|
372
507
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -447,7 +582,23 @@ function handleMcp(opts) {
|
|
|
447
582
|
})
|
|
448
583
|
.finally(() => {
|
|
449
584
|
// Also `void`: the proxy runs for the life of the process and is never awaited by anyone.
|
|
450
|
-
|
|
585
|
+
//
|
|
586
|
+
// The `.catch` is load-bearing despite that. `startMcpProxy` rejects when its FIRST connect
|
|
587
|
+
// fails — which is precisely the case this block exists to tolerate, a daemon that is not
|
|
588
|
+
// there yet — and with nothing attached that reject became an unhandledRejection. Reported
|
|
589
|
+
// from a win32 user as a crash reading `connect ECONNREFUSED` with an EMPTY frame list,
|
|
590
|
+
// because the stack of a refused socket is entirely node internals and the privacy filter
|
|
591
|
+
// keeps only Reticle frames. So the one path we most want diagnosable arrived as an anonymous
|
|
592
|
+
// crash. The proxy itself is unaffected — it has already installed its stdin reader and goes
|
|
593
|
+
// on serving from cache, waking a daemon on the next request — which is exactly why this
|
|
594
|
+
// must be logged as the expected condition it is rather than reported as a defect.
|
|
595
|
+
void startMcpProxy(port, ensure).catch((err) => {
|
|
596
|
+
log('reticle_mcp_proxy_first_connect_failed', {
|
|
597
|
+
port,
|
|
598
|
+
error: err instanceof Error ? err.message : String(err),
|
|
599
|
+
note: 'serving from cache; the next client request will try to start a daemon',
|
|
600
|
+
});
|
|
601
|
+
});
|
|
451
602
|
});
|
|
452
603
|
}
|
|
453
604
|
function handleLegacyDrive(parsed) {
|
|
@@ -528,7 +679,7 @@ function main() {
|
|
|
528
679
|
handleTelemetry(parsed.action);
|
|
529
680
|
break;
|
|
530
681
|
case 'feedback':
|
|
531
|
-
void handleFeedback(parsed.text, parsed.rating, parsed.bug);
|
|
682
|
+
void handleFeedback(parsed.text, parsed.rating, parsed.bug, parsed.feedbackKind, parsed.agent);
|
|
532
683
|
break;
|
|
533
684
|
case 'identify':
|
|
534
685
|
void handleIdentify(parsed);
|
|
@@ -31,6 +31,30 @@ export type CrashKind = (typeof CrashKind)[keyof typeof CrashKind];
|
|
|
31
31
|
* is the point: after this, silence in the log is itself the finding, because every other door is
|
|
32
32
|
* now instrumented.
|
|
33
33
|
*/
|
|
34
|
+
/**
|
|
35
|
+
* Why the daemon is going away — carried ON the exit line, not left to be inferred.
|
|
36
|
+
*
|
|
37
|
+
* From the field (#123): a real gate log read `reticle_daemon_signalled SIGTERM` then
|
|
38
|
+
* `reticle_daemon_exiting code:0`, then 21 seconds with nothing listening. `code: 0` is the last
|
|
39
|
+
* line before the port goes dark and it cannot distinguish "shut down tidily" from "the bridge every
|
|
40
|
+
* app on this machine needs is now gone". A correct install was written up as a failure naming the
|
|
41
|
+
* fixture because of it.
|
|
42
|
+
*
|
|
43
|
+
* `UNKNOWN` is the load-bearing member, not a fallback: it means the process left through Node
|
|
44
|
+
* WITHOUT passing a shutdown path — an uncaught throw, a stray `process.exit`. That is a different
|
|
45
|
+
* fact from an idle exit, and the one worth noticing.
|
|
46
|
+
*/
|
|
47
|
+
export declare const DaemonExitReason: {
|
|
48
|
+
/** A shutdown signal arrived (SIGTERM/SIGINT). */
|
|
49
|
+
readonly SIGNAL: "signal";
|
|
50
|
+
/** The idle timer fired and the daemon retired itself. */
|
|
51
|
+
readonly IDLE: "idle";
|
|
52
|
+
/** Left through Node, but not via any shutdown path we own. */
|
|
53
|
+
readonly UNKNOWN: "unknown";
|
|
54
|
+
};
|
|
55
|
+
export type DaemonExitReason = (typeof DaemonExitReason)[keyof typeof DaemonExitReason];
|
|
56
|
+
/** Record why the daemon is shutting down. Called by a shutdown path before it exits. */
|
|
57
|
+
export declare function recordExitReason(reason: DaemonExitReason): void;
|
|
34
58
|
export declare function installExitTrace(proc: ProcessLike, log: LogFn): void;
|
|
35
59
|
export declare function installDaemonResilience(proc: ProcessLike, log: LogFn, onFatal: () => void): void;
|
|
36
60
|
/**
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* per Node's guidance, so LOG a clear reason and exit cleanly; the next `reticle mcp` respawns a fresh
|
|
9
9
|
* daemon, which beats crashing silently or limping along corrupt.
|
|
10
10
|
*/
|
|
11
|
-
import { TelemetryActor, TelemetryEventKind } from '@reticlehq/core';
|
|
11
|
+
import { RETICLE_DEFAULT_PORT, TelemetryActor, TelemetryEventKind } from '@reticlehq/core';
|
|
12
12
|
import { errorSkeleton, errorTypeOf, fingerprintCrash, MAX_REPORTED_FRAMES, reticleFrames, } from '../telemetry/error-fingerprint.js';
|
|
13
13
|
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
14
14
|
import { machineSnapshot } from '../telemetry/machine-snapshot.js';
|
|
15
|
+
import { crashCause } from '../telemetry/crash-cause.js';
|
|
15
16
|
import { getTelemetry } from '../telemetry/telemetry.js';
|
|
16
17
|
function describe(value) {
|
|
17
18
|
return value instanceof Error ? value.message : String(value);
|
|
@@ -38,23 +39,48 @@ const DISCONNECT_CODES = new Set([
|
|
|
38
39
|
'ECONNRESET',
|
|
39
40
|
'ERR_STREAM_DESTROYED',
|
|
40
41
|
]);
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Nobody went away — nobody was ever there. A refused connect is the daemon not being up YET.
|
|
44
|
+
*
|
|
45
|
+
* This is the difference that made `runtime_crashed` useless. In the field **every crash event
|
|
46
|
+
* carried one fingerprint, `connect ECONNREFUSED`, and every affected user was in the
|
|
47
|
+
* never-verified set.** The proxy is BUILT to tolerate this — it answers the handshake from cache
|
|
48
|
+
* and wakes a daemon on the next request — so reporting it as a crash meant the one metric that is
|
|
49
|
+
* supposed to say "Reticle broke" said nothing but "the daemon had not booted yet", and a real crash
|
|
50
|
+
* would have been invisible underneath it.
|
|
51
|
+
*
|
|
52
|
+
* Absorbed, not silenced: it still logs, and the outage it belongs to is already counted as
|
|
53
|
+
* `connect_error` on `mcp_connection_lost`, which is the metric that can actually act on it.
|
|
54
|
+
*/
|
|
55
|
+
const UNREACHABLE_CODES = new Set(['ECONNREFUSED']);
|
|
56
|
+
/** The expected-failure code this value carries, and which class it belongs to. */
|
|
57
|
+
function expectedCode(value) {
|
|
58
|
+
const raw = value?.code;
|
|
59
|
+
if ('string' !== typeof raw)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (DISCONNECT_CODES.has(raw))
|
|
62
|
+
return { code: raw, unreachable: false };
|
|
63
|
+
if (UNREACHABLE_CODES.has(raw))
|
|
64
|
+
return { code: raw, unreachable: true };
|
|
65
|
+
return undefined;
|
|
45
66
|
}
|
|
46
67
|
/**
|
|
47
|
-
* Log an expected
|
|
68
|
+
* Log an expected failure and report `true`, so the caller skips the crash report.
|
|
48
69
|
*
|
|
49
70
|
* It is still WRITTEN DOWN. A client leaving is normal, but a daemon that suddenly logs a hundred of
|
|
50
71
|
* these is a finding — the difference between "not a crash" and "not worth knowing" is the whole
|
|
51
72
|
* reason this logs instead of returning early in silence.
|
|
52
73
|
*/
|
|
53
|
-
function
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
74
|
+
function absorbExpected(value, log, events) {
|
|
75
|
+
const hit = expectedCode(value);
|
|
76
|
+
if (hit === undefined)
|
|
56
77
|
return false;
|
|
57
|
-
log(
|
|
78
|
+
log(hit.unreachable ? events.unreachable : events.disconnected, {
|
|
79
|
+
code: hit.code,
|
|
80
|
+
note: hit.unreachable
|
|
81
|
+
? 'the daemon was not reachable — expected while it boots, not a crash'
|
|
82
|
+
: 'the client went away — expected, not a crash',
|
|
83
|
+
});
|
|
58
84
|
return true;
|
|
59
85
|
}
|
|
60
86
|
/** The two ways a failure reaches the top of the process. Named so the analytics can tell them apart. */
|
|
@@ -62,6 +88,19 @@ export const CrashKind = {
|
|
|
62
88
|
UNHANDLED_REJECTION: 'unhandled_rejection',
|
|
63
89
|
UNCAUGHT_EXCEPTION: 'uncaught_exception',
|
|
64
90
|
};
|
|
91
|
+
/**
|
|
92
|
+
* The ports Reticle itself uses, so a refusal can be classified as ours or somebody else's.
|
|
93
|
+
*
|
|
94
|
+
* Read here rather than threaded through `installCrashHandlers`: a crash handler is installed once,
|
|
95
|
+
* at startup, and the port can be set after it. The values are only ever compared against — the
|
|
96
|
+
* port a crash names is turned into an enum and then discarded.
|
|
97
|
+
*/
|
|
98
|
+
function knownPorts() {
|
|
99
|
+
const configured = Number(process.env['RETICLE_PORT']);
|
|
100
|
+
return Number.isInteger(configured) && configured > 0
|
|
101
|
+
? [RETICLE_DEFAULT_PORT, configured]
|
|
102
|
+
: [RETICLE_DEFAULT_PORT];
|
|
103
|
+
}
|
|
65
104
|
/**
|
|
66
105
|
* Report a crash with enough detail to actually diagnose it.
|
|
67
106
|
*
|
|
@@ -85,6 +124,10 @@ function reportCrash(kind, value) {
|
|
|
85
124
|
const message = describe(value);
|
|
86
125
|
const { breadcrumb, inFlight } = getSessionMetrics().trail;
|
|
87
126
|
const machine = machineSnapshot();
|
|
127
|
+
// Where the frames cannot say. A refused socket's stack is entirely node internals, so
|
|
128
|
+
// `reticleFrames` correctly keeps nothing and the report lands with no location at all — the
|
|
129
|
+
// single commonest crash shape in a loopback-heavy tool.
|
|
130
|
+
const cause = crashCause(value, knownPorts());
|
|
88
131
|
void getTelemetry().emit(TelemetryEventKind.RUNTIME_CRASHED, {
|
|
89
132
|
// A crash is ALWAYS reached through something the agent asked for — the daemon does nothing on
|
|
90
133
|
// its own — so attributing it to the agent is accurate rather than a guess.
|
|
@@ -106,6 +149,9 @@ function reportCrash(kind, value) {
|
|
|
106
149
|
arch: process.arch,
|
|
107
150
|
// "Out of memory" and "our bug" look identical in a stack trace. This is what tells them apart.
|
|
108
151
|
...(machine !== undefined ? { machine } : {}),
|
|
152
|
+
// The syscall, the errno, whether it was loopback, whether the port was ours, and the
|
|
153
|
+
// innermost frame in NODE's own source. Present only when the error carries them.
|
|
154
|
+
...cause,
|
|
109
155
|
},
|
|
110
156
|
});
|
|
111
157
|
}
|
|
@@ -126,9 +172,45 @@ function reportCrash(kind, value) {
|
|
|
126
172
|
* is the point: after this, silence in the log is itself the finding, because every other door is
|
|
127
173
|
* now instrumented.
|
|
128
174
|
*/
|
|
175
|
+
/**
|
|
176
|
+
* Why the daemon is going away — carried ON the exit line, not left to be inferred.
|
|
177
|
+
*
|
|
178
|
+
* From the field (#123): a real gate log read `reticle_daemon_signalled SIGTERM` then
|
|
179
|
+
* `reticle_daemon_exiting code:0`, then 21 seconds with nothing listening. `code: 0` is the last
|
|
180
|
+
* line before the port goes dark and it cannot distinguish "shut down tidily" from "the bridge every
|
|
181
|
+
* app on this machine needs is now gone". A correct install was written up as a failure naming the
|
|
182
|
+
* fixture because of it.
|
|
183
|
+
*
|
|
184
|
+
* `UNKNOWN` is the load-bearing member, not a fallback: it means the process left through Node
|
|
185
|
+
* WITHOUT passing a shutdown path — an uncaught throw, a stray `process.exit`. That is a different
|
|
186
|
+
* fact from an idle exit, and the one worth noticing.
|
|
187
|
+
*/
|
|
188
|
+
export const DaemonExitReason = {
|
|
189
|
+
/** A shutdown signal arrived (SIGTERM/SIGINT). */
|
|
190
|
+
SIGNAL: 'signal',
|
|
191
|
+
/** The idle timer fired and the daemon retired itself. */
|
|
192
|
+
IDLE: 'idle',
|
|
193
|
+
/** Left through Node, but not via any shutdown path we own. */
|
|
194
|
+
UNKNOWN: 'unknown',
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Set by whichever shutdown path is running, read once by the exit handler.
|
|
198
|
+
*
|
|
199
|
+
* A module-level value rather than a parameter because the two are separated by the whole process
|
|
200
|
+
* lifetime: the reason is known when shutdown STARTS and the exit line is written when Node is on
|
|
201
|
+
* its way out. Exit runs once, on one thread, after everything else — so there is nothing to race.
|
|
202
|
+
*/
|
|
203
|
+
let exitReason = DaemonExitReason.UNKNOWN;
|
|
204
|
+
/** Record why the daemon is shutting down. Called by a shutdown path before it exits. */
|
|
205
|
+
export function recordExitReason(reason) {
|
|
206
|
+
exitReason = reason;
|
|
207
|
+
}
|
|
129
208
|
export function installExitTrace(proc, log) {
|
|
130
209
|
proc.on('exit', (code) => {
|
|
131
|
-
log('reticle_daemon_exiting', {
|
|
210
|
+
log('reticle_daemon_exiting', {
|
|
211
|
+
code: 'number' === typeof code ? code : null,
|
|
212
|
+
reason: exitReason,
|
|
213
|
+
});
|
|
132
214
|
});
|
|
133
215
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
134
216
|
proc.on(signal, () => {
|
|
@@ -140,9 +222,12 @@ export function installExitTrace(proc, log) {
|
|
|
140
222
|
}
|
|
141
223
|
export function installDaemonResilience(proc, log, onFatal) {
|
|
142
224
|
installExitTrace(proc, log);
|
|
143
|
-
const
|
|
225
|
+
const expected = {
|
|
226
|
+
disconnected: 'reticle_daemon_client_disconnected',
|
|
227
|
+
unreachable: 'reticle_daemon_peer_unreachable',
|
|
228
|
+
};
|
|
144
229
|
proc.on('unhandledRejection', (reason) => {
|
|
145
|
-
if (
|
|
230
|
+
if (absorbExpected(reason, log, expected))
|
|
146
231
|
return;
|
|
147
232
|
log('reticle_daemon_unhandled_rejection', { reason: describe(reason) });
|
|
148
233
|
reportCrash(CrashKind.UNHANDLED_REJECTION, reason);
|
|
@@ -151,7 +236,7 @@ export function installDaemonResilience(proc, log, onFatal) {
|
|
|
151
236
|
// Not fatal either: Node's "the process state is undefined" guidance is about a throw that
|
|
152
237
|
// escaped everything, not about a write to a socket somebody closed. Exiting here would let one
|
|
153
238
|
// departing client take down the daemon serving every other agent.
|
|
154
|
-
if (
|
|
239
|
+
if (absorbExpected(err, log, expected))
|
|
155
240
|
return;
|
|
156
241
|
log('reticle_daemon_uncaught_exception', { error: describe(err) });
|
|
157
242
|
reportCrash(CrashKind.UNCAUGHT_EXCEPTION, err);
|
|
@@ -171,16 +256,19 @@ export function installDaemonResilience(proc, log, onFatal) {
|
|
|
171
256
|
* `onCrash` is for reporting only — it must not end the process.
|
|
172
257
|
*/
|
|
173
258
|
export function installProxyResilience(proc, log, onCrash = reportCrash) {
|
|
174
|
-
const
|
|
259
|
+
const expected = {
|
|
260
|
+
disconnected: 'reticle_mcp_proxy_client_disconnected',
|
|
261
|
+
unreachable: 'reticle_mcp_proxy_daemon_unreachable',
|
|
262
|
+
};
|
|
175
263
|
proc.on('unhandledRejection', (reason) => {
|
|
176
|
-
if (
|
|
264
|
+
if (absorbExpected(reason, log, expected))
|
|
177
265
|
return;
|
|
178
266
|
log('reticle_mcp_proxy_unhandled_rejection', { reason: describe(reason) });
|
|
179
267
|
onCrash(CrashKind.UNHANDLED_REJECTION, reason);
|
|
180
268
|
});
|
|
181
269
|
proc.on('uncaughtException', (err) => {
|
|
182
270
|
// The client closing its end of stdio is how this process is SUPPOSED to end its day.
|
|
183
|
-
if (
|
|
271
|
+
if (absorbExpected(err, log, expected))
|
|
184
272
|
return;
|
|
185
273
|
log('reticle_mcp_proxy_uncaught_exception', {
|
|
186
274
|
error: describe(err),
|