@reticlehq/server 3.1.0 → 3.2.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 +17 -18
- package/dist/command/cli/auth/device-flow.d.ts +2 -2
- package/dist/command/cli/cli-doctor.js +13 -1
- package/dist/command/cli/cli-flow-commands.d.ts +2 -1
- package/dist/command/cli/cli-kill.js +1 -1
- package/dist/command/cli/cli-parse.d.ts +2 -1
- package/dist/command/cli/cli-parse.js +5 -2
- package/dist/command/cli/cli-verify.js +2 -2
- package/dist/command/cli/cloud-cli.js +7 -6
- package/dist/command/cli/cloud-kit.js +2 -6
- package/dist/command/cli/config/config-discovery.d.ts +12 -0
- package/dist/command/cli/config/config-discovery.js +19 -0
- package/dist/command/cli/doctor/doctor-rows.d.ts +12 -0
- package/dist/command/cli/doctor/doctor-rows.js +12 -0
- package/dist/command/cli/drive/drive-command.js +1 -1
- package/dist/command/cli/launch/cli-launch.d.ts +10 -2
- package/dist/command/cli/launch/cli-launch.js +32 -39
- package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +33 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +198 -0
- package/dist/command/cli/mcp-command.js +5 -3
- package/dist/command/cli/ports/resolve/cli-port.d.ts +2 -1
- package/dist/command/cli/ports/resolve/cli-port.js +5 -1
- package/dist/command/cli/status/status-lines.d.ts +28 -0
- package/dist/command/cli/status/status-lines.js +117 -0
- package/dist/command/cli.d.ts +1 -1
- package/dist/command/cli.js +24 -177
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
- package/dist/command/daemon/binding/daemon-status-probe.js +48 -0
- package/dist/command/daemon/binding/loopback-reach.d.ts +42 -0
- package/dist/command/daemon/binding/loopback-reach.js +63 -0
- package/dist/command/daemon/daemon-resilience.d.ts +13 -1
- package/dist/command/daemon/daemon-resilience.js +12 -1
- package/dist/command/daemon/daemon.d.ts +2 -11
- package/dist/command/daemon/daemon.js +8 -11
- package/dist/command/daemon/lifetime/heartbeat.d.ts +8 -0
- package/dist/command/daemon/lifetime/heartbeat.js +10 -0
- package/dist/command/daemon/lifetime/log-cap.d.ts +68 -0
- package/dist/command/daemon/lifetime/log-cap.js +115 -0
- package/dist/command/setup/agent-notice-memory.d.ts +16 -0
- package/dist/command/setup/agent-notice-memory.js +65 -0
- package/dist/command/setup/bringup/dev-server-wait.d.ts +1 -0
- package/dist/command/setup/bringup/dev-server-wait.js +16 -0
- package/dist/command/setup/bringup/ensure-daemon.js +1 -1
- package/dist/command/setup/init/init-runtime.js +13 -1
- package/dist/command/setup/node-effects.js +1 -1
- package/dist/command/setup/probe/page-probe.js +9 -2
- package/dist/command/setup/run-setup.d.ts +25 -7
- package/dist/command/setup/run-setup.js +51 -9
- package/dist/command/setup/setup-command.js +31 -3
- package/dist/command/setup/terminal/confirm.js +2 -1
- package/dist/command/update/updater.d.ts +27 -0
- package/dist/command/update/updater.js +35 -2
- package/dist/features/crawl/crawl-tools.js +3 -1
- package/dist/features/crawl/crawl.js +2 -1
- package/dist/features/harness/drive-report.d.ts +73 -0
- package/dist/features/harness/drive-report.js +196 -0
- package/dist/features/harness/driver.js +29 -8
- package/dist/features/harness/drivers.d.ts +19 -0
- package/dist/features/harness/drivers.js +19 -0
- package/dist/features/harness/fill-values.d.ts +67 -0
- package/dist/features/harness/fill-values.js +153 -0
- package/dist/features/harness/jev-driver.d.ts +188 -0
- package/dist/features/harness/jev-driver.js +664 -0
- package/dist/features/harness/openai-driver.d.ts +46 -0
- package/dist/features/harness/openai-driver.js +197 -0
- package/dist/features/harness/platform-config.d.ts +52 -0
- package/dist/features/harness/platform-config.js +89 -0
- package/dist/features/visual/visual-tools.js +26 -2
- package/dist/index.js +60 -55
- package/dist/judgement/domain/domain-tools.js +4 -2
- package/dist/judgement/runs/verification-sync.d.ts +2 -2
- package/dist/judgement/runs/verification-sync.js +9 -2
- package/dist/language/flows/affected-tools.js +6 -3
- package/dist/language/flows/flow-replay-run.d.ts +25 -2
- package/dist/language/flows/flow-replay-run.js +73 -27
- package/dist/language/flows/flow-replay.d.ts +17 -0
- package/dist/language/flows/flow-replay.js +148 -100
- package/dist/language/flows/flow-result.d.ts +18 -1
- package/dist/language/flows/flow-result.js +18 -2
- package/dist/language/flows/flow-store-for-session.d.ts +2 -1
- package/dist/language/flows/flow-tools.js +8 -4
- package/dist/language/flows/flows.d.ts +8 -7
- package/dist/language/flows/flows.js +7 -3
- package/dist/language/flows/narrow-write.d.ts +4 -4
- package/dist/language/flows/replay.js +1 -1
- package/dist/language/flows/suite-selection.d.ts +15 -1
- package/dist/language/flows/suite-selection.js +1 -1
- package/dist/language/flows/verify-change-tools.js +7 -4
- package/dist/memory/cloud/account-state.d.ts +1 -1
- package/dist/memory/cloud/account-state.js +5 -4
- package/dist/memory/cloud/cloud-config.d.ts +9 -0
- package/dist/memory/cloud/cloud-config.js +9 -0
- package/dist/memory/cloud/cloud-sync.d.ts +7 -2
- package/dist/memory/cloud/cloud-sync.js +10 -5
- package/dist/memory/cloud/harness-config.d.ts +10 -0
- package/dist/memory/cloud/harness-config.js +58 -0
- package/dist/memory/cloud/harness-offer.d.ts +44 -0
- package/dist/memory/cloud/harness-offer.js +146 -0
- package/dist/memory/cloud/harness-switch.d.ts +11 -0
- package/dist/memory/cloud/harness-switch.js +53 -0
- package/dist/memory/cloud/sync-daemon.d.ts +22 -2
- package/dist/memory/cloud/sync-daemon.js +24 -0
- package/dist/memory/impact/impact-recorder.d.ts +8 -0
- package/dist/memory/impact/impact-recorder.js +2 -1
- package/dist/memory/impact/impact-store.d.ts +5 -0
- package/dist/memory/impact/impact-store.js +19 -0
- package/dist/memory/intent/intent-shard-store.js +2 -2
- package/dist/memory/intent/intent-shard.d.ts +12 -12
- package/dist/memory/journal/attach-journal.d.ts +11 -0
- package/dist/memory/journal/attach-journal.js +1 -1
- package/dist/memory/journal/journal-query.d.ts +35 -1
- package/dist/memory/journal/journal-query.js +46 -0
- package/dist/memory/journal/journal-recorder.d.ts +50 -2
- package/dist/memory/journal/journal-recorder.js +45 -7
- package/dist/memory/journal/on-disk/retention.d.ts +87 -2
- package/dist/memory/journal/on-disk/retention.js +177 -8
- package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +9 -0
- package/dist/memory/journal/on-disk/workspace-gitignore.js +9 -2
- package/dist/memory/journal/self-observation.d.ts +3 -0
- package/dist/memory/journal/self-observation.js +67 -0
- package/dist/memory/journal/session-end.d.ts +13 -3
- package/dist/memory/journal/session-end.js +24 -4
- package/dist/memory/journal/session-journal.d.ts +115 -5
- package/dist/memory/journal/session-journal.js +479 -32
- package/dist/memory/project/artifact-root-resolver.d.ts +3 -0
- package/dist/memory/project/artifact-root-resolver.js +89 -0
- package/dist/memory/project/artifact-root.d.ts +39 -2
- package/dist/memory/project/artifact-root.js +58 -1
- package/dist/memory/project/dir/fill-value-store.d.ts +32 -0
- package/dist/memory/project/dir/fill-value-store.js +88 -0
- package/dist/memory/project/dir/reticle-dir.d.ts +14 -4
- package/dist/memory/project/dir/reticle-dir.js +11 -0
- package/dist/memory/project/fs/fs-port.d.ts +22 -1
- package/dist/memory/project/fs/fs-port.js +33 -5
- package/dist/memory/project/learned-routes.d.ts +16 -2
- package/dist/memory/project/learned-routes.js +26 -10
- package/dist/memory/project/project-for-root.d.ts +30 -0
- package/dist/memory/project/project-for-root.js +43 -0
- package/dist/memory/project/project-tools.js +10 -5
- package/dist/memory/project/session-root.d.ts +35 -6
- package/dist/memory/project/session-root.js +70 -8
- package/dist/portal/bridge/auth-failure-reason.d.ts +9 -0
- package/dist/portal/bridge/auth-failure-reason.js +15 -1
- package/dist/portal/bridge/bridge.d.ts +6 -1
- package/dist/portal/bridge/bridge.js +36 -2
- package/dist/portal/session/facts/session-replaced.d.ts +19 -0
- package/dist/portal/session/facts/session-replaced.js +22 -0
- package/dist/portal/session/no-session-diagnosis.d.ts +1 -0
- package/dist/portal/session/no-session-diagnosis.js +67 -60
- package/dist/portal/session/no-session-next-action.js +3 -2
- package/dist/portal/session/no-session-watch.js +14 -10
- package/dist/portal/session/rebind.d.ts +18 -0
- package/dist/portal/session/rebind.js +38 -0
- package/dist/portal/session/session-manager.d.ts +4 -0
- package/dist/portal/session/session-manager.js +19 -1
- package/dist/portal/session/session.d.ts +21 -10
- package/dist/portal/session/session.js +29 -42
- package/dist/status-payload.d.ts +12 -1
- package/dist/status-payload.js +4 -1
- package/dist/surface/http-server.js +3 -5
- package/dist/surface/mcp/faults/mcp-outage.d.ts +2 -1
- package/dist/surface/mcp/faults/mcp-outage.js +20 -4
- package/dist/surface/mcp/mcp-proxy.d.ts +10 -1
- package/dist/surface/mcp/mcp-proxy.js +20 -9
- package/dist/surface/mcp/mcp.d.ts +10 -1
- package/dist/surface/mcp/mcp.js +59 -29
- package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +15 -2
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +22 -5
- package/dist/surface/mcp/proxy/proxy-log.d.ts +5 -11
- package/dist/surface/mcp/proxy/proxy-log.js +7 -22
- package/dist/surface/tools/act/act-preflight.js +6 -1
- package/dist/surface/tools/act/sequence-grade.js +13 -0
- package/dist/surface/tools/act-capsule.js +27 -1
- package/dist/surface/tools/assert/assert-verdict.js +44 -17
- package/dist/surface/tools/coverage-tools.js +5 -2
- package/dist/surface/tools/dynamic-tools.js +37 -46
- package/dist/surface/tools/error-recovery.d.ts +6 -0
- package/dist/surface/tools/error-recovery.js +15 -0
- package/dist/surface/tools/explore-tools.js +80 -7
- package/dist/surface/tools/feedback-tools.js +49 -2
- package/dist/surface/tools/first-sentence.d.ts +24 -0
- package/dist/surface/tools/first-sentence.js +0 -0
- package/dist/surface/tools/harness-explore.d.ts +125 -1
- package/dist/surface/tools/harness-explore.js +401 -15
- package/dist/surface/tools/harness-plan.d.ts +76 -0
- package/dist/surface/tools/harness-plan.js +156 -0
- package/dist/surface/tools/harness-toolset.js +12 -1
- package/dist/surface/tools/invoke-tool.js +65 -5
- package/dist/surface/tools/live-call-text.d.ts +6 -0
- package/dist/surface/tools/live-call-text.js +36 -2
- package/dist/surface/tools/observe-tools.js +55 -9
- package/dist/surface/tools/read/query-windowed.d.ts +1 -1
- package/dist/surface/tools/read/query-windowed.js +1 -1
- package/dist/surface/tools/read-tools.js +1 -2
- package/dist/surface/tools/tool-kit.d.ts +18 -3
- package/dist/surface/tools/tools-helpers.d.ts +8 -7
- package/dist/surface/tools/tools-helpers.js +11 -13
- package/dist/surface/tools/tools.js +6 -1
- package/dist/telemetry/bug-found.js +32 -2
- package/dist/telemetry/feedback-context.js +3 -3
- package/dist/telemetry/harness-drive.d.ts +6 -0
- package/dist/telemetry/harness-drive.js +39 -0
- package/dist/telemetry/mcp-connection.d.ts +14 -1
- package/dist/telemetry/mcp-connection.js +17 -2
- package/dist/telemetry/onboarding-firsts.d.ts +30 -1
- package/dist/telemetry/onboarding-firsts.js +54 -12
- package/dist/telemetry/verification-of.js +31 -3
- package/docs/agent-cheatsheet.md +7 -3
- package/docs/autodrive.md +42 -2
- package/docs/benchmarks.md +1 -1
- package/docs/capabilities.mdx +1 -1
- package/docs/cli/cloud.mdx +3 -3
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/status.mdx +35 -20
- package/docs/cli.mdx +8 -18
- package/docs/desktop.mdx +1 -1
- package/docs/faq.mdx +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/predicates.mdx +16 -0
- package/docs/system-map.md +6 -4
- package/docs/telemetry.md +2 -1
- package/docs/token-efficiency.md +2 -2
- package/docs/tools/overview.mdx +1 -1
- package/docs/troubleshooting.mdx +8 -2
- package/docs/usage.md +1 -1
- package/package.json +6 -6
package/dist/command/cli.js
CHANGED
|
@@ -8,7 +8,6 @@ import { openFailureNote } from './cli/answers/open-note.js';
|
|
|
8
8
|
import { realpathSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { installCrashGuard } from './setup/setup-command.js';
|
|
11
|
-
import { stateDirProblem } from './daemon/state-dir.js';
|
|
12
11
|
import { readDevServers } from './daemon/dev-servers.js';
|
|
13
12
|
import { hasAnyAppConnectedBefore, hasProjectConnectedBefore, } from '../memory/recall/prior/connection-memory.js';
|
|
14
13
|
import { attachStatusFields } from '../surface/mcp/attach-memory.js';
|
|
@@ -16,7 +15,7 @@ import { splitBrainFields, withNextAction } from './cli/status-fields.js';
|
|
|
16
15
|
import { reticleStateHome } from './daemon/daemon.js';
|
|
17
16
|
import { handleMcp } from './cli/mcp-command.js';
|
|
18
17
|
import { resolveDaemonForProject } from './daemon/daemon-resolve.js';
|
|
19
|
-
import {
|
|
18
|
+
import { daemonStartOptions } from './cli/daemon-start-options.js';
|
|
20
19
|
import { handleWatch, handleCapsules, handleGate, loadNamedFlows, resolveChangedFiles, } from './cli/cli-flow-commands.js';
|
|
21
20
|
import { RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv, devServersForProject, } from '@reticlehq/core';
|
|
22
21
|
import { loadDotEnv } from '../telemetry/dev-repo.js';
|
|
@@ -26,20 +25,20 @@ import { createNodeFileSystem } from '../memory/project/fs/fs-port.js';
|
|
|
26
25
|
import { affectedSavedFlows } from '../language/flows/change/flow-sources.js';
|
|
27
26
|
import { availableUpdate } from './update/update-nudge.js';
|
|
28
27
|
import { handleUpdate, handleRollback } from './cli/cli-update-commands.js';
|
|
29
|
-
import { startDaemon
|
|
30
|
-
import { verifyEndpointMismatch } from '../status-payload.js';
|
|
28
|
+
import { startDaemon } from '../index.js';
|
|
31
29
|
import { isCloudCommand, runCloudCommand } from './cli/cloud-cli.js';
|
|
32
30
|
import { SERVER_VERSION } from './version/identity/server-version.js';
|
|
33
31
|
import { log } from '../log.js';
|
|
34
|
-
import { readPid, isAlive, removePid, spawnDaemon, discoverDaemonPort, writeDaemonRegistry,
|
|
32
|
+
import { readPid, isAlive, removePid, spawnDaemon, discoverDaemonPort, writeDaemonRegistry, } from './daemon/daemon.js';
|
|
35
33
|
import { PortPresence, probePresence, presenceIsUsable, describePresence, } from './daemon/binding/port-presence.js';
|
|
36
34
|
import { waitForDaemon, probeDaemon } from '../surface/mcp/mcp-proxy.js';
|
|
37
35
|
import { installDaemonResilience, recordExitReason, DaemonExitReason, } from './daemon/daemon-resilience.js';
|
|
38
36
|
import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
|
|
39
37
|
import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/lifetime/heartbeat.js';
|
|
40
38
|
import { everServedToolCall } from './daemon/lifetime/daemon-usefulness.js';
|
|
41
|
-
import { DAEMON_START_FAILED_EVENT
|
|
42
|
-
import {
|
|
39
|
+
import { DAEMON_START_FAILED_EVENT } from './daemon/lifetime/startup-failure.js';
|
|
40
|
+
import { summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, openCommand, } from './cli/launch/cli-launch.js';
|
|
41
|
+
import { fetchStatus } from './daemon/binding/daemon-status-probe.js';
|
|
43
42
|
import { handleDrive } from './cli/drive/drive-command.js';
|
|
44
43
|
import { handleVerify } from './cli/cli-verify.js';
|
|
45
44
|
import { runKill } from './cli/cli-kill.js';
|
|
@@ -77,178 +76,26 @@ function handleInit(parsed) {
|
|
|
77
76
|
}, io);
|
|
78
77
|
void continueAfterInit(parsed, result, io, cwd);
|
|
79
78
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
* Report the BIND, not the spawn.
|
|
85
|
-
*
|
|
86
|
-
* `serve` used to log `reticle_daemon_spawned` and exit 0 unconditionally, while the child died at
|
|
87
|
-
* `reticle_daemon_start_failed` with an EADDRINUSE nothing joined back to the parent. Three surfaces
|
|
88
|
-
* then disagreed about what was true and none of them named the port.
|
|
89
|
-
*
|
|
90
|
-
* Two changes, both about saying what is actually the case: refuse up front when the port is held by
|
|
91
|
-
* something that is not a Reticle daemon (a spawn there cannot succeed), and after spawning, wait
|
|
92
|
-
* for the daemon to actually answer before claiming it started.
|
|
93
|
-
*/
|
|
94
|
-
async function serveWithHonestExit(parsed) {
|
|
95
|
-
const presence = await probePresence(parsed.port, {
|
|
96
|
-
tcpOpen: probeDaemon,
|
|
97
|
-
status: fetchStatus,
|
|
98
|
-
});
|
|
99
|
-
if (presence === PortPresence.DAEMON) {
|
|
100
|
-
// A daemon that is already up was started with ITS flags, not these — `serve` only attaches.
|
|
101
|
-
// Exiting 0 here regardless is how `--http-port` came to be accepted and ignored (#687): the
|
|
102
|
-
// running daemon kept serving whatever it was started with, and the flag vanished without a
|
|
103
|
-
// word. Ask the daemon which verify port it actually serves and refuse when it is not the one
|
|
104
|
-
// requested — a flag that cannot be honoured must say so, not report success.
|
|
105
|
-
if (parsed.http) {
|
|
106
|
-
const mismatch = verifyEndpointMismatch(await fetchStatus(parsed.port), parsed.httpPort ?? RETICLE_VERIFY_DEFAULT_PORT);
|
|
107
|
-
if (mismatch !== undefined) {
|
|
108
|
-
log('reticle_daemon_start_refused', { port: parsed.port, reason: mismatch });
|
|
109
|
-
process.stderr.write(`${mismatch}\n`);
|
|
110
|
-
process.exit(1);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
log('reticle_daemon_already_running', { port: parsed.port });
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
if (presence === PortPresence.FOREIGN) {
|
|
118
|
-
log('reticle_daemon_start_refused', {
|
|
119
|
-
port: parsed.port,
|
|
120
|
-
presence,
|
|
121
|
-
reason: describePresence(presence, parsed.port),
|
|
122
|
-
});
|
|
123
|
-
process.stderr.write(`${describePresence(presence, parsed.port)}\n`);
|
|
124
|
-
process.exit(1);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
const scriptPath = process.argv[1];
|
|
128
|
-
if (scriptPath === undefined) {
|
|
129
|
-
log('reticle_serve_no_script', {});
|
|
130
|
-
process.exit(1);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
const daemonArgs = daemonSpawnArgs(parsed);
|
|
134
|
-
const startupStartedAt = Date.now();
|
|
135
|
-
spawnDaemon(process.execPath, scriptPath, daemonArgs, parsed.port);
|
|
136
|
-
// The child binds asynchronously and, when it cannot, exits 1 long after this process would have
|
|
137
|
-
// reported success. Wait for it to ANSWER — `/status` responding is the only evidence a daemon
|
|
138
|
-
// exists that does not come from the pid file the child may never have earned.
|
|
139
|
-
const bound = await waitForPresence(parsed.port, PortPresence.DAEMON, SERVE_BIND_TIMEOUT_MS);
|
|
140
|
-
if (!bound) {
|
|
141
|
-
const settled = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
142
|
-
const startupCause = readDaemonStartupCause(logPath(parsed.port), startupStartedAt);
|
|
143
|
-
log('reticle_daemon_start_failed_parent', {
|
|
144
|
-
port: parsed.port,
|
|
145
|
-
presence: settled,
|
|
146
|
-
reason: describePresence(settled, parsed.port),
|
|
147
|
-
log: logPath(parsed.port),
|
|
148
|
-
stateDir: stateDirProblem(reticleStateHome()) === undefined ? 'writable' : 'unwritable',
|
|
149
|
-
...(startupCause === undefined ? {} : { cause: startupCause }),
|
|
150
|
-
});
|
|
151
|
-
// Name the CAUSE when we can see it. An unwritable state directory is a first-run failure mode
|
|
152
|
-
// (locked-down home, read-only container mount, managed profile) that produced only "nothing is
|
|
153
|
-
// listening" — the symptom restated — and then pointed at a log INSIDE that directory, which
|
|
154
|
-
// cannot exist. Reproduced with `chmod 555 ~/.reticle`.
|
|
155
|
-
const dirProblem = stateDirProblem(reticleStateHome());
|
|
156
|
-
process.stderr.write(dirProblem === undefined
|
|
157
|
-
? startupCause === undefined
|
|
158
|
-
? `the daemon did not come up on :${String(parsed.port)} — ${describePresence(settled, parsed.port)}\n` +
|
|
159
|
-
`see ${logPath(parsed.port)}\n`
|
|
160
|
-
: `the daemon did not come up on :${String(parsed.port)}.\n${startupCause}\n` +
|
|
161
|
-
`see ${logPath(parsed.port)}\n`
|
|
162
|
-
: `the daemon did not come up on :${String(parsed.port)}.\n${dirProblem}\n`);
|
|
163
|
-
process.exit(1);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
log('reticle_daemon_spawned', { port: parsed.port, ...(parsed.http ? { http: true } : {}) });
|
|
167
|
-
}
|
|
168
|
-
/** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
|
|
169
|
-
const SERVE_BIND_TIMEOUT_MS = 15_000;
|
|
170
|
-
const PRESENCE_POLL_MS = 150;
|
|
171
|
-
/** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
|
|
172
|
-
const GRACEFUL_STOP_MS = 5000;
|
|
173
|
-
/** How long after SIGKILL before we accept the process is not ours to kill and say so. */
|
|
174
|
-
const FORCED_STOP_MS = 2000;
|
|
175
|
-
async function waitForPresence(port, want, timeoutMs) {
|
|
176
|
-
const deadline = Date.now() + timeoutMs;
|
|
177
|
-
for (;;) {
|
|
178
|
-
const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
179
|
-
if (presence === want)
|
|
180
|
-
return true;
|
|
181
|
-
if (Date.now() >= deadline)
|
|
182
|
-
return false;
|
|
183
|
-
await new Promise((resolve) => setTimeout(resolve, PRESENCE_POLL_MS));
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
function handleStop(port, quiet) {
|
|
187
|
-
const pid = readPid(port);
|
|
188
|
-
if (null === pid || !isAlive(pid)) {
|
|
189
|
-
removePid(port);
|
|
190
|
-
if (!quiet)
|
|
191
|
-
log('reticle_daemon_not_running', { port });
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
process.kill(pid, 'SIGTERM');
|
|
195
|
-
const started = Date.now();
|
|
196
|
-
let escalated = false;
|
|
197
|
-
const poll = setInterval(() => {
|
|
198
|
-
if (!isAlive(pid)) {
|
|
199
|
-
clearInterval(poll);
|
|
200
|
-
removePid(port);
|
|
201
|
-
if (!quiet)
|
|
202
|
-
log('reticle_daemon_stopped', { port, pid, ...(escalated ? { escalated } : {}) });
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const waited = Date.now() - started;
|
|
206
|
-
// A daemon that ignores SIGTERM is WEDGED — which is the one situation `stop` exists for. Giving
|
|
207
|
-
// up here left the port held, the pid file stale, and a human running `kill -9` by hand at the
|
|
208
|
-
// first two minutes of setup. Escalate instead: graceful first, then unconditional.
|
|
209
|
-
if (!escalated && waited > GRACEFUL_STOP_MS) {
|
|
210
|
-
escalated = true;
|
|
211
|
-
try {
|
|
212
|
-
process.kill(pid, 'SIGKILL');
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
// Already gone between the liveness check and here — the next tick reports it stopped.
|
|
216
|
-
}
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
if (waited > GRACEFUL_STOP_MS + FORCED_STOP_MS) {
|
|
220
|
-
clearInterval(poll);
|
|
221
|
-
// Surviving SIGKILL means the pid is not ours to kill (permissions) or is an unkillable
|
|
222
|
-
// zombie. Neither is retryable, and both need the pid named so a human can act on it.
|
|
223
|
-
if (!quiet)
|
|
224
|
-
log('reticle_daemon_stop_timeout', { port, pid, escalated });
|
|
225
|
-
process.exit(1);
|
|
226
|
-
}
|
|
227
|
-
}, 100);
|
|
228
|
-
}
|
|
79
|
+
// `serve`, `stop` and `restart` live in `cli/lifecycle/daemon-lifecycle.ts`: one idea, and the
|
|
80
|
+
// largest group this file could give up without splitting something that belongs together.
|
|
81
|
+
import { handleServe, handleStop, handleRestart } from './cli/lifecycle/daemon-lifecycle.js';
|
|
82
|
+
import { statusLines } from './cli/status/status-lines.js';
|
|
229
83
|
/**
|
|
230
|
-
*
|
|
84
|
+
* Log the status event AND print it for a person.
|
|
231
85
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
86
|
+
* Both, not either: the JSON line is documented and is what a log is for, and the block is for
|
|
87
|
+
* whoever is reading the terminal — which, once an agent is running the command, is somebody who
|
|
88
|
+
* did not type it and cannot be assumed to parse it.
|
|
235
89
|
*/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
log('reticle_restart_aborted', {
|
|
240
|
-
port,
|
|
241
|
-
reason: 'the port was not freed, so nothing was started — the old holder is still there',
|
|
242
|
-
});
|
|
243
|
-
process.exit(1);
|
|
90
|
+
function reportStatus(fields, json) {
|
|
91
|
+
if (json) {
|
|
92
|
+
log('reticle_status', fields);
|
|
244
93
|
return;
|
|
245
94
|
}
|
|
246
|
-
|
|
247
|
-
// non-zero when it does not. A restart that reports success for a daemon that never bound is the
|
|
248
|
-
// same lie `serve` already stopped telling.
|
|
249
|
-
await serveWithHonestExit({ port, headless: true, http: false });
|
|
95
|
+
process.stdout.write(`${statusLines(fields).join('\n')}\n`);
|
|
250
96
|
}
|
|
251
|
-
export async function handleStatus(port) {
|
|
97
|
+
export async function handleStatus(port, json = false) {
|
|
98
|
+
const report = (fields) => reportStatus(fields, json);
|
|
252
99
|
const pid = readPid(port);
|
|
253
100
|
// Durable, so it survives the daemon idling out — which is the state `status` is most often run in.
|
|
254
101
|
const projectId = readProjectId(process.cwd());
|
|
@@ -281,7 +128,7 @@ export async function handleStatus(port) {
|
|
|
281
128
|
// process exists; a Reticle daemon is running only when the shared port probe reaches /status.
|
|
282
129
|
const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
283
130
|
if (!presenceIsUsable(presence)) {
|
|
284
|
-
|
|
131
|
+
report({
|
|
285
132
|
port,
|
|
286
133
|
running: false,
|
|
287
134
|
presence,
|
|
@@ -310,7 +157,7 @@ export async function handleStatus(port) {
|
|
|
310
157
|
const update = availableUpdate();
|
|
311
158
|
const nudge = update === undefined ? {} : { updateAvailable: update };
|
|
312
159
|
if (payload === undefined) {
|
|
313
|
-
|
|
160
|
+
report({
|
|
314
161
|
port,
|
|
315
162
|
running: true,
|
|
316
163
|
pid,
|
|
@@ -342,7 +189,7 @@ export async function handleStatus(port) {
|
|
|
342
189
|
devServerPorts,
|
|
343
190
|
})
|
|
344
191
|
: {};
|
|
345
|
-
|
|
192
|
+
report({
|
|
346
193
|
port,
|
|
347
194
|
running: true,
|
|
348
195
|
pid,
|
|
@@ -752,7 +599,7 @@ export function main() {
|
|
|
752
599
|
void handleRestart(parsed.port, parsed.force);
|
|
753
600
|
break;
|
|
754
601
|
case 'status':
|
|
755
|
-
void handleStatus(parsed.port);
|
|
602
|
+
void handleStatus(parsed.port, parsed.json);
|
|
756
603
|
break;
|
|
757
604
|
case 'license':
|
|
758
605
|
handleLicense();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import { LOOPBACK_HOST, STATUS_PATH } from '@reticlehq/core';
|
|
3
|
+
import { loopbackAgent } from '../../../surface/loopback-agent.js';
|
|
4
|
+
/**
|
|
5
|
+
* The impure half of `probePresence`: ask the port whether a Reticle daemon is answering.
|
|
6
|
+
*
|
|
7
|
+
* It lives beside `port-presence.ts` rather than in `launch/` because every caller of
|
|
8
|
+
* `probePresence` passes it — status, doctor, kill, verify, drive, the daemon lifecycle and the
|
|
9
|
+
* MCP wake path all spell `probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus })`.
|
|
10
|
+
* Filed under `launch/` it read as a launcher helper, and it is not one; the proxy could not reach
|
|
11
|
+
* it there without inventing a dependency on a directory it has no other business with.
|
|
12
|
+
*
|
|
13
|
+
* The rule it feeds stays pure and socket-free in `port-presence.ts`. That split is deliberate: a
|
|
14
|
+
* probe that starts lying is a different failure from a rule that does.
|
|
15
|
+
*/
|
|
16
|
+
/** How long the daemon /status probe waits before giving up — a local loopback call is near-instant. */
|
|
17
|
+
const STATUS_PROBE_TIMEOUT_MS = 1000;
|
|
18
|
+
/** GET the daemon's /status JSON. Resolves to the parsed body, or undefined on any failure. */
|
|
19
|
+
export function fetchStatus(port) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const req = http.get({
|
|
22
|
+
host: LOOPBACK_HOST,
|
|
23
|
+
port,
|
|
24
|
+
path: STATUS_PATH,
|
|
25
|
+
timeout: STATUS_PROBE_TIMEOUT_MS,
|
|
26
|
+
// Shares the proxy's keep-alive agent: the proxy calls this on every reconnect, and a socket
|
|
27
|
+
// per probe is the same churn the POST leg was fixed for.
|
|
28
|
+
agent: loopbackAgent,
|
|
29
|
+
}, (res) => {
|
|
30
|
+
let body = '';
|
|
31
|
+
res.setEncoding('utf8');
|
|
32
|
+
res.on('data', (chunk) => (body += chunk));
|
|
33
|
+
res.on('end', () => {
|
|
34
|
+
try {
|
|
35
|
+
resolve(JSON.parse(body));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
resolve(undefined);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
req.on('error', () => resolve(undefined));
|
|
43
|
+
req.on('timeout', () => {
|
|
44
|
+
req.destroy();
|
|
45
|
+
resolve(undefined);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of `localhost`'s two answers can actually reach the bridge.
|
|
3
|
+
*
|
|
4
|
+
* `localhost` is a name with two answers, and Windows Chrome tries the IPv6 one first. The daemon
|
|
5
|
+
* binds `127.0.0.1`, so `loopback-alias.ts` forwards `[::1]:port` to it — best-effort by contract,
|
|
6
|
+
* because a machine with IPv6 off or something already holding `[::1]:port` must not stop the daemon
|
|
7
|
+
* serving. When that alias does not open, the daemon is serving perfectly on IPv4 and the page's
|
|
8
|
+
* documented default URL cannot reach it. The daemon's own view is "the bridge was up for the whole
|
|
9
|
+
* window", and it is right.
|
|
10
|
+
*
|
|
11
|
+
* Nothing said which half was missing. The one record was a field on the `mcp_daemon_started` log
|
|
12
|
+
* line, so the condition that makes `localhost` unreachable on the platform with most of our
|
|
13
|
+
* installs was invisible unless somebody read the daemon's JSON. The install gate's Windows cell
|
|
14
|
+
* reports exactly this shape — `init` exits 1 with "the SDK IS in the page and never dialled", while
|
|
15
|
+
* the page's own console says it could not open a websocket to `ws://localhost:<port>` after three
|
|
16
|
+
* attempts, and the very next boot of the same scaffold connects — and none of the three causes that
|
|
17
|
+
* diagnosis offers is this one.
|
|
18
|
+
*
|
|
19
|
+
* This answers it by connecting, at the moment of the diagnosis, rather than by remembering what
|
|
20
|
+
* happened at startup: a probe is true about now, and a startup flag is true about startup.
|
|
21
|
+
*/
|
|
22
|
+
/** Which loopback addresses answered on the bridge port. */
|
|
23
|
+
export interface LoopbackReach {
|
|
24
|
+
/** `127.0.0.1` answered — what the daemon binds. */
|
|
25
|
+
readonly v4: boolean;
|
|
26
|
+
/** `[::1]` answered — what `localhost` resolves to first on Windows. */
|
|
27
|
+
readonly v6: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Probe both halves of loopback on the bridge port.
|
|
31
|
+
*
|
|
32
|
+
* Both are attempted even when the first answers: the interesting result is the DISAGREEMENT, and
|
|
33
|
+
* learning that v4 works tells you nothing about the address the browser will actually pick.
|
|
34
|
+
*/
|
|
35
|
+
export declare function probeLoopbackReach(port: number): Promise<LoopbackReach>;
|
|
36
|
+
/**
|
|
37
|
+
* True when the bridge is reachable as an IPv4 address and NOT as the name the SDK is told to dial.
|
|
38
|
+
*
|
|
39
|
+
* This is the whole condition worth naming. Both up is healthy; both down is an ordinary "the daemon
|
|
40
|
+
* is not there" that every other check already reports; v6-only cannot happen on a `127.0.0.1` bind.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isLocalhostSplit(reach: LoopbackReach): boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of `localhost`'s two answers can actually reach the bridge.
|
|
3
|
+
*
|
|
4
|
+
* `localhost` is a name with two answers, and Windows Chrome tries the IPv6 one first. The daemon
|
|
5
|
+
* binds `127.0.0.1`, so `loopback-alias.ts` forwards `[::1]:port` to it — best-effort by contract,
|
|
6
|
+
* because a machine with IPv6 off or something already holding `[::1]:port` must not stop the daemon
|
|
7
|
+
* serving. When that alias does not open, the daemon is serving perfectly on IPv4 and the page's
|
|
8
|
+
* documented default URL cannot reach it. The daemon's own view is "the bridge was up for the whole
|
|
9
|
+
* window", and it is right.
|
|
10
|
+
*
|
|
11
|
+
* Nothing said which half was missing. The one record was a field on the `mcp_daemon_started` log
|
|
12
|
+
* line, so the condition that makes `localhost` unreachable on the platform with most of our
|
|
13
|
+
* installs was invisible unless somebody read the daemon's JSON. The install gate's Windows cell
|
|
14
|
+
* reports exactly this shape — `init` exits 1 with "the SDK IS in the page and never dialled", while
|
|
15
|
+
* the page's own console says it could not open a websocket to `ws://localhost:<port>` after three
|
|
16
|
+
* attempts, and the very next boot of the same scaffold connects — and none of the three causes that
|
|
17
|
+
* diagnosis offers is this one.
|
|
18
|
+
*
|
|
19
|
+
* This answers it by connecting, at the moment of the diagnosis, rather than by remembering what
|
|
20
|
+
* happened at startup: a probe is true about now, and a startup flag is true about startup.
|
|
21
|
+
*/
|
|
22
|
+
import net from 'node:net';
|
|
23
|
+
import { LOOPBACK_HOST } from '@reticlehq/core';
|
|
24
|
+
import { IPV6_LOOPBACK } from './loopback-alias.js';
|
|
25
|
+
/** Long enough for a loopback TCP handshake, short enough to sit inside a diagnosis. */
|
|
26
|
+
const REACH_TIMEOUT_MS = 500;
|
|
27
|
+
/** Can a TCP connection be opened to this address and port at all. */
|
|
28
|
+
function canConnect(host, port) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const socket = net.connect({ host, port });
|
|
31
|
+
const settle = (answer) => {
|
|
32
|
+
socket.destroy();
|
|
33
|
+
resolve(answer);
|
|
34
|
+
};
|
|
35
|
+
socket.setTimeout(REACH_TIMEOUT_MS, () => settle(false));
|
|
36
|
+
socket.once('connect', () => settle(true));
|
|
37
|
+
// Refused, unreachable, IPv6 disabled entirely — all of them are "this address does not answer",
|
|
38
|
+
// which is the only question being asked. An unhandled 'error' here would take down the caller.
|
|
39
|
+
socket.once('error', () => settle(false));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Probe both halves of loopback on the bridge port.
|
|
44
|
+
*
|
|
45
|
+
* Both are attempted even when the first answers: the interesting result is the DISAGREEMENT, and
|
|
46
|
+
* learning that v4 works tells you nothing about the address the browser will actually pick.
|
|
47
|
+
*/
|
|
48
|
+
export async function probeLoopbackReach(port) {
|
|
49
|
+
const [v4, v6] = await Promise.all([
|
|
50
|
+
canConnect(LOOPBACK_HOST, port),
|
|
51
|
+
canConnect(IPV6_LOOPBACK, port),
|
|
52
|
+
]);
|
|
53
|
+
return { v4, v6 };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* True when the bridge is reachable as an IPv4 address and NOT as the name the SDK is told to dial.
|
|
57
|
+
*
|
|
58
|
+
* This is the whole condition worth naming. Both up is healthy; both down is an ordinary "the daemon
|
|
59
|
+
* is not there" that every other check already reports; v6-only cannot happen on a `127.0.0.1` bind.
|
|
60
|
+
*/
|
|
61
|
+
export function isLocalhostSplit(reach) {
|
|
62
|
+
return reach.v4 && !reach.v6;
|
|
63
|
+
}
|
|
@@ -57,6 +57,18 @@ export type DaemonExitReason = (typeof DaemonExitReason)[keyof typeof DaemonExit
|
|
|
57
57
|
export declare function recordExitReason(reason: DaemonExitReason): void;
|
|
58
58
|
export declare function installExitTrace(proc: ProcessLike, log: LogFn): void;
|
|
59
59
|
export declare function installDaemonResilience(proc: ProcessLike, log: LogFn, onFatal: () => void): void;
|
|
60
|
+
/** The handle the proxy keeps, so it can say when a session proved itself usable. */
|
|
61
|
+
export interface ProxyResilience {
|
|
62
|
+
/**
|
|
63
|
+
* A stream reached an `endpoint` frame: there is a working session, so whatever disconnects came
|
|
64
|
+
* before it were reconnects rather than a runaway.
|
|
65
|
+
*
|
|
66
|
+
* The same signal that already earns a fresh RETRY budget in `mcp-proxy.ts`, and chosen there for
|
|
67
|
+
* the same reason: response headers are produced by a daemon that accepts SSE and drops it, and an
|
|
68
|
+
* `endpoint` frame is not.
|
|
69
|
+
*/
|
|
70
|
+
noteUsableSession(): void;
|
|
71
|
+
}
|
|
60
72
|
/**
|
|
61
73
|
* Process-level resilience for the MCP PROXY, whose rule is the opposite of the daemon's.
|
|
62
74
|
*
|
|
@@ -70,5 +82,5 @@ export declare function installDaemonResilience(proc: ProcessLike, log: LogFn, o
|
|
|
70
82
|
* `onCrash` is for reporting only — it must not end the process. `onGone` is the ONE exception, and
|
|
71
83
|
* it is the subject of DISCONNECT_STORM below.
|
|
72
84
|
*/
|
|
73
|
-
export declare function installProxyResilience(proc: ProcessLike, log: LogFn, onCrash?: (kind: CrashKind, cause: unknown) => void, onGone?: () => void):
|
|
85
|
+
export declare function installProxyResilience(proc: ProcessLike, log: LogFn, onCrash?: (kind: CrashKind, cause: unknown) => void, onGone?: () => void): ProxyResilience;
|
|
74
86
|
export {};
|
|
@@ -290,7 +290,10 @@ export function installProxyResilience(proc, log, onCrash = reportCrash, onGone
|
|
|
290
290
|
unreachable: 'reticle_mcp_proxy_daemon_unreachable',
|
|
291
291
|
};
|
|
292
292
|
// "Absorb the disconnect and keep serving" is right for ONE disconnect and catastrophic for a
|
|
293
|
-
// stream of them — see DISCONNECT_STORM.
|
|
293
|
+
// stream of them — see DISCONNECT_STORM. Counted since the last USABLE SESSION rather than since
|
|
294
|
+
// this process started: a lifetime tally reaches the threshold on a working week, because the
|
|
295
|
+
// classifier reads `ECONNRESET` as "the peer vanished" and on this leg the peer is the DAEMON,
|
|
296
|
+
// which restarts routinely and is the thing the proxy exists to survive.
|
|
294
297
|
let disconnects = 0;
|
|
295
298
|
let gone = false;
|
|
296
299
|
const absorb = (value) => {
|
|
@@ -309,6 +312,13 @@ export function installProxyResilience(proc, log, onCrash = reportCrash, onGone
|
|
|
309
312
|
}
|
|
310
313
|
return true;
|
|
311
314
|
};
|
|
315
|
+
const noteUsableSession = () => {
|
|
316
|
+
// Not after `gone`: the process is already leaving, and a late frame must not look like a
|
|
317
|
+
// reason to stay. Nothing brings the proxy back from `onGone` by design.
|
|
318
|
+
if (gone)
|
|
319
|
+
return;
|
|
320
|
+
disconnects = 0;
|
|
321
|
+
};
|
|
312
322
|
proc.on('unhandledRejection', (reason) => {
|
|
313
323
|
if (absorb(reason))
|
|
314
324
|
return;
|
|
@@ -325,4 +335,5 @@ export function installProxyResilience(proc, log, onCrash = reportCrash, onGone
|
|
|
325
335
|
});
|
|
326
336
|
onCrash(CrashKind.UNCAUGHT_EXCEPTION, err);
|
|
327
337
|
});
|
|
338
|
+
return { noteUsableSession };
|
|
328
339
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { MAX_DAEMON_LOG_BYTES, recoverOversizedLog } from './lifetime/log-cap.js';
|
|
1
2
|
/** Env override for the whole state directory — see ReticleEnv.STATE_DIR. */
|
|
2
3
|
export declare const STATE_DIR_ENV: "RETICLE_STATE_DIR";
|
|
3
4
|
/**
|
|
@@ -10,20 +11,10 @@ export declare const STATE_DIR_ENV: "RETICLE_STATE_DIR";
|
|
|
10
11
|
*/
|
|
11
12
|
export declare function reticleStateHome(): string;
|
|
12
13
|
export declare function logPath(port: number): string;
|
|
13
|
-
/**
|
|
14
|
-
* How large a daemon log may get before it is rolled over to `<name>.1`.
|
|
15
|
-
*
|
|
16
|
-
* It was unbounded. A real dev machine reached **24MB** on one port, which is not merely untidy: it
|
|
17
|
-
* is the difference between a log somebody opens and a log somebody gives up on, and it is disk that
|
|
18
|
-
* nothing ever reclaims. One previous generation is kept, because the question people bring to this
|
|
19
|
-
* file ("what happened just now?") is answered by the current one and the question they bring next
|
|
20
|
-
* ("and just before that?") is answered by the other.
|
|
21
|
-
*/
|
|
22
|
-
export declare const MAX_DAEMON_LOG_BYTES: number;
|
|
23
14
|
/**
|
|
24
15
|
* Roll the log over if it has grown past the cap. Best-effort: a daemon must still start when the
|
|
25
16
|
* log cannot be rotated, because failing to launch over housekeeping is strictly worse than a large
|
|
26
|
-
* file.
|
|
17
|
+
* file. The cap itself, the mid-run check and the prune live in lifetime/log-cap.ts.
|
|
27
18
|
*/
|
|
28
19
|
export declare function rotateDaemonLog(path: string, deps: {
|
|
29
20
|
fileSize(p: string): number;
|
|
@@ -5,6 +5,10 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import { daemonRegistryFileName, ReticleEnv } from '@reticlehq/core';
|
|
6
6
|
import { log } from '../../log.js';
|
|
7
7
|
import { uncleanPredecessor } from './lifetime/unclean-predecessor.js';
|
|
8
|
+
import { MAX_DAEMON_LOG_BYTES, pruneOldDaemonLogs } from './lifetime/log-cap.js';
|
|
9
|
+
// Re-exported from their owner so callers outside this directory — the proxy log, the rotation
|
|
10
|
+
// tests — keep reaching `daemon` rather than `lifetime`, which is a sink. See lifetime/log-cap.ts.
|
|
11
|
+
export { MAX_DAEMON_LOG_BYTES, recoverOversizedLog } from './lifetime/log-cap.js';
|
|
8
12
|
/** Env override for the whole state directory — see ReticleEnv.STATE_DIR. */
|
|
9
13
|
export const STATE_DIR_ENV = ReticleEnv.STATE_DIR;
|
|
10
14
|
/**
|
|
@@ -30,20 +34,10 @@ function registryPath(port) {
|
|
|
30
34
|
export function logPath(port) {
|
|
31
35
|
return join(reticleStateHome(), `daemon-${port}.log`);
|
|
32
36
|
}
|
|
33
|
-
/**
|
|
34
|
-
* How large a daemon log may get before it is rolled over to `<name>.1`.
|
|
35
|
-
*
|
|
36
|
-
* It was unbounded. A real dev machine reached **24MB** on one port, which is not merely untidy: it
|
|
37
|
-
* is the difference between a log somebody opens and a log somebody gives up on, and it is disk that
|
|
38
|
-
* nothing ever reclaims. One previous generation is kept, because the question people bring to this
|
|
39
|
-
* file ("what happened just now?") is answered by the current one and the question they bring next
|
|
40
|
-
* ("and just before that?") is answered by the other.
|
|
41
|
-
*/
|
|
42
|
-
export const MAX_DAEMON_LOG_BYTES = 8 * 1024 * 1024;
|
|
43
37
|
/**
|
|
44
38
|
* Roll the log over if it has grown past the cap. Best-effort: a daemon must still start when the
|
|
45
39
|
* log cannot be rotated, because failing to launch over housekeeping is strictly worse than a large
|
|
46
|
-
* file.
|
|
40
|
+
* file. The cap itself, the mid-run check and the prune live in lifetime/log-cap.ts.
|
|
47
41
|
*/
|
|
48
42
|
export function rotateDaemonLog(path, deps, max = MAX_DAEMON_LOG_BYTES) {
|
|
49
43
|
try {
|
|
@@ -259,6 +253,9 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
|
|
|
259
253
|
// Roll the log BEFORE opening the append handle, or the daemon writes into the renamed file
|
|
260
254
|
// through an fd that no longer matches any name anyone can find.
|
|
261
255
|
rotateDaemonLog(logFilePath, deps);
|
|
256
|
+
// Every OTHER port's leftovers, while we are already here and holding the directory. The clock is
|
|
257
|
+
// read at this I/O boundary rather than injected, same as the timestamp written below.
|
|
258
|
+
pruneOldDaemonLogs(deps.home, Date.now());
|
|
262
259
|
let logFd;
|
|
263
260
|
try {
|
|
264
261
|
logFd = deps.openFile(logFilePath, 'a');
|
|
@@ -51,6 +51,14 @@ interface DaemonHeartbeatOptions {
|
|
|
51
51
|
intervalMs?: number;
|
|
52
52
|
/** Injected, per the repo rule: never call Date.now() inside logic. */
|
|
53
53
|
clock?: () => number;
|
|
54
|
+
/**
|
|
55
|
+
* The log file this beat writes into, for the size check that rides along with it. Injected only
|
|
56
|
+
* by tests; the default is the daemon's own stderr. See guardDaemonLog.
|
|
57
|
+
*/
|
|
58
|
+
logGuard?: {
|
|
59
|
+
size(): number;
|
|
60
|
+
truncate(): void;
|
|
61
|
+
};
|
|
54
62
|
}
|
|
55
63
|
export declare class DaemonHeartbeat {
|
|
56
64
|
#private;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* A heartbeat nobody interprets is just log volume.
|
|
19
19
|
*/
|
|
20
|
+
import { DAEMON_LOG_TRUNCATED_EVENT, guardDaemonLog } from './log-cap.js';
|
|
20
21
|
/** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
|
|
21
22
|
export const DAEMON_HEARTBEAT_EVENT = 'reticle_daemon_alive';
|
|
22
23
|
/** Written by installExitTrace when the process exits through Node. */
|
|
@@ -55,6 +56,7 @@ export class DaemonHeartbeat {
|
|
|
55
56
|
#facts;
|
|
56
57
|
#intervalMs;
|
|
57
58
|
#clock;
|
|
59
|
+
#logGuard;
|
|
58
60
|
#startedAt;
|
|
59
61
|
#timer;
|
|
60
62
|
constructor(opts) {
|
|
@@ -62,6 +64,7 @@ export class DaemonHeartbeat {
|
|
|
62
64
|
this.#facts = opts.facts;
|
|
63
65
|
this.#intervalMs = opts.intervalMs ?? DAEMON_HEARTBEAT_MS;
|
|
64
66
|
this.#clock = opts.clock ?? (() => Date.now());
|
|
67
|
+
this.#logGuard = opts.logGuard;
|
|
65
68
|
this.#startedAt = this.#clock();
|
|
66
69
|
}
|
|
67
70
|
/**
|
|
@@ -72,6 +75,13 @@ export class DaemonHeartbeat {
|
|
|
72
75
|
* ambiguity this exists to remove.
|
|
73
76
|
*/
|
|
74
77
|
beat() {
|
|
78
|
+
// The one thing a running daemon does on a timer, so it is also where the log it is filling gets
|
|
79
|
+
// looked at. `rotateDaemonLog` runs at spawn and never again: a detached daemon then writes for
|
|
80
|
+
// days with nothing checking the size, which is how a log reaches its cap with no mechanism left
|
|
81
|
+
// that would ever trim it. The beat is itself one of those writes.
|
|
82
|
+
const reclaimed = guardDaemonLog(this.#logGuard);
|
|
83
|
+
if (0 < reclaimed)
|
|
84
|
+
this.#log(DAEMON_LOG_TRUNCATED_EVENT, { reclaimedBytes: reclaimed });
|
|
75
85
|
const facts = this.#facts();
|
|
76
86
|
this.#log(DAEMON_HEARTBEAT_EVENT, {
|
|
77
87
|
uptimeMs: this.#clock() - this.#startedAt,
|