@akira-tl/forgerelay 1.3.5 → 1.3.6-beta.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.3.6-beta.1] - 2026-11-05
8
+
9
+ ### Fixed
10
+
11
+ - 语言服务回收前重新检查服务归属、活动请求和最近使用时间,避免并发文件修改重新使用服务后,清理任务仍按旧名单关闭它;服务整体关闭时等待已开始的操作完成。
12
+ - Pyright 互操作验证直接使用真实语言服务器协议请求,避免因不支持 `--version` 而被误跳过。
13
+
14
+ ### Added
15
+
16
+ - 默认在实例状态目录的 `logs/runtime.jsonl` 持久记录语言服务启动、关闭原因、信号发送位置、操作异常和主进程未捕获异常;同步写入、限制大小并轮换保存,不依赖终端滚屏。
17
+ - 支持 beta 补丁版本,经现有发布验证后发布到 npm `next` 通道并标记为 GitHub 预发布。
18
+
7
19
  ## [1.3.5] - 2026-11-04
8
20
 
9
21
  ### Fixed
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { join, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { loadConfig } from "./runtime/config/config.js";
11
11
  import { acquireRuntimeLease } from "./runtime/state/runtime-lease.js";
12
+ import { diagnosticError, observeRuntimeExit, RuntimeDiagnostics } from "./runtime/logging/runtime-diagnostics.js";
12
13
  import { runInit } from "./cli/init.js";
13
14
  import { runConfigMigration } from "./cli/config/migrate.js";
14
15
  import { runConfigInspection } from "./cli/config/inspect.js";
@@ -198,11 +199,16 @@ async function serve(runtimePrivilege, runtimeOverrides = {}) {
198
199
  const config = loadConfig(process.env, { runtimeOverrides });
199
200
  config.runtimePrivilege = runtimePrivilege;
200
201
  const runtimeLease = acquireRuntimeLease(config.stateDir);
202
+ const diagnostics = new RuntimeDiagnostics(config.stateDir);
203
+ const stopObservingExit = observeRuntimeExit(diagnostics);
204
+ diagnostics.record("runtime_started", { version: installedForgeRelayVersion(), reason: process.version });
201
205
  let server;
202
206
  try {
203
- server = createServer(config);
207
+ server = createServer(config, { runtimeDiagnostics: diagnostics });
204
208
  }
205
209
  catch (error) {
210
+ diagnostics.record("runtime_start_failed", diagnosticError(error));
211
+ stopObservingExit();
206
212
  runtimeLease.release();
207
213
  throw error;
208
214
  }
@@ -219,6 +225,7 @@ async function serve(runtimePrivilege, runtimeOverrides = {}) {
219
225
  console.log(`command shell: ${formatCommandShellRuntime(config.commandShellRuntime)}`);
220
226
  console.log(`runtime privilege: ${formatRuntimePrivilege(runtimePrivilege)}`);
221
227
  console.log(`logging: ${config.logging.level} ${config.logging.format}`);
228
+ console.log(`runtime diagnostics: ${diagnostics.path}`);
222
229
  if (config.subagents) {
223
230
  console.log(`subagent providers: ${formatSubagentProviderAvailabilitySummary(subagentProviders)}`);
224
231
  }
@@ -239,14 +246,15 @@ async function serve(runtimePrivilege, runtimeOverrides = {}) {
239
246
  }
240
247
  process.exit(0);
241
248
  };
242
- const handleShutdown = () => {
249
+ const handleShutdown = (signal) => {
250
+ diagnostics.record("runtime_signal", { signal });
243
251
  void shutdown().catch((error) => {
244
252
  console.error("forgerelay shutdown failed", error);
245
253
  process.exit(1);
246
254
  });
247
255
  };
248
- process.once("SIGINT", handleShutdown);
249
- process.once("SIGTERM", handleShutdown);
256
+ process.once("SIGINT", () => handleShutdown("SIGINT"));
257
+ process.once("SIGTERM", () => handleShutdown("SIGTERM"));
250
258
  }
251
259
  async function runDoctor() {
252
260
  const files = loadForgeRelayFiles();
@@ -7,6 +7,8 @@ import { DefinitionRequest, DiagnosticTag, DocumentDiagnosticReportKind, Documen
7
7
  import { LanguageServerConfigurationError, } from "./language-server-config.js";
8
8
  import { terminateProcessTree } from "../mcp/process/process-platform.js";
9
9
  import { CodeIntelligenceError } from "./code-intelligence-error.js";
10
+ import { randomUUID } from "node:crypto";
11
+ import { diagnosticError, } from "../runtime/logging/runtime-diagnostics.js";
10
12
  import { DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT } from "./code-intelligence-types.js";
11
13
  import { normalizeHoverContents } from "./normalization/hover.js";
12
14
  import { normalizeDocumentSymbols, normalizeWorkspaceSymbols, } from "./normalization/symbols.js";
@@ -14,7 +16,7 @@ import { isWithin, locationEntries, normalizeLocations, workspaceDisplayPath, }
14
16
  import { lspPositionFromUser, rangeFromLsp } from "./position-encoding.js";
15
17
  import { DiagnosticSnapshotStore } from "./runtime/diagnostic-snapshots.js";
16
18
  import { DocumentSynchronizer } from "./runtime/document-synchronizer.js";
17
- import { languageServerCommandRequiresShell } from "./runtime/process-launch.js";
19
+ import { languageServerCommandRequiresShell, waitForChildExit, waitForChildSpawn, withTimeout, } from "./runtime/process-launch.js";
18
20
  import { SemanticRequestCoordinator } from "./runtime/semantic-requests.js";
19
21
  export { CodeIntelligenceError } from "./code-intelligence-error.js";
20
22
  const STDERR_TAIL_BYTES = 64 * 1024;
@@ -36,6 +38,7 @@ export class LanguageService {
36
38
  semanticRequests;
37
39
  stderrTail = Buffer.alloc(0);
38
40
  closed = false;
41
+ serviceId = randomUUID();
39
42
  constructor(workspaceRoot, project, policy) {
40
43
  this.workspaceRoot = workspaceRoot;
41
44
  this.project = project;
@@ -279,9 +282,10 @@ export class LanguageService {
279
282
  throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
280
283
  }
281
284
  }
282
- async shutdown() {
285
+ async shutdown(reason = "server_shutdown") {
283
286
  if (this.closed)
284
287
  return;
288
+ this.recordDiagnostic("lsp_shutdown", { reason, ...diagnosticError(new Error()) });
285
289
  this.closed = true;
286
290
  const connection = this.connection;
287
291
  const child = this.child;
@@ -302,6 +306,7 @@ export class LanguageService {
302
306
  this.documents.clear();
303
307
  this.diagnosticSnapshots.clear();
304
308
  if (child && child.exitCode === null && child.signalCode === null) {
309
+ this.recordDiagnostic("lsp_terminate", { reason, signal: "SIGTERM", ...diagnosticError(new Error()) });
305
310
  terminateProcessTree(child, "SIGTERM", process.platform !== "win32");
306
311
  }
307
312
  this.child = undefined;
@@ -324,6 +329,7 @@ export class LanguageService {
324
329
  }
325
330
  catch (error) {
326
331
  this.initializePromise = undefined;
332
+ this.recordDiagnostic("lsp_start_failed", diagnosticError(error));
327
333
  this.cleanupFailedStart();
328
334
  if (error instanceof CodeIntelligenceError)
329
335
  throw error;
@@ -348,6 +354,10 @@ export class LanguageService {
348
354
  throw new CodeIntelligenceError("code.language_service_start_failed", `Unable to start Language server ${definition.id}: ${errorMessage(error)}.`);
349
355
  }
350
356
  this.child = child;
357
+ this.recordDiagnostic("lsp_start");
358
+ child.on("exit", (exitCode, signal) => {
359
+ this.recordDiagnostic("lsp_exit", { childPid: child.pid, exitCode, signal });
360
+ });
351
361
  child.stderr.on("data", (chunk) => this.appendStderr(chunk));
352
362
  await waitForChildSpawn(child, definition.id);
353
363
  const connection = createMessageConnection(child.stdout, child.stdin);
@@ -430,6 +440,7 @@ export class LanguageService {
430
440
  try {
431
441
  const result = await withTimeout(Promise.race([initialize, startupFailure]), this.policy.startTimeoutMs, () => new CodeIntelligenceError("code.language_service_start_timeout", `Language server ${definition.id} did not initialize within ${this.policy.startTimeoutMs}ms.`));
432
442
  await connection.sendNotification(InitializedNotification.type, {});
443
+ this.recordDiagnostic("lsp_ready");
433
444
  return result;
434
445
  }
435
446
  finally {
@@ -441,6 +452,9 @@ export class LanguageService {
441
452
  this.connection?.dispose();
442
453
  const child = this.child;
443
454
  if (child && child.exitCode === null && child.signalCode === null) {
455
+ this.recordDiagnostic("lsp_terminate", {
456
+ reason: "initialization_failed", signal: "SIGTERM", ...diagnosticError(new Error()),
457
+ });
444
458
  terminateProcessTree(child, "SIGTERM", process.platform !== "win32");
445
459
  }
446
460
  this.connection = undefined;
@@ -448,6 +462,18 @@ export class LanguageService {
448
462
  this.documents.clear();
449
463
  this.diagnosticSnapshots.clear();
450
464
  }
465
+ recordDiagnostic(event, fields = {}) {
466
+ this.policy.diagnostics?.record(event, {
467
+ serviceId: this.serviceId,
468
+ serverId: this.project.definition.id,
469
+ projectRoot: this.project.projectRoot,
470
+ childPid: this.child?.pid,
471
+ operationsInFlight: this.inFlight,
472
+ semanticRequestsActive: this.semanticRequests.activeCount,
473
+ semanticRequestsQueued: this.semanticRequests.queuedCount,
474
+ ...fields,
475
+ });
476
+ }
451
477
  registerClientHandlers(connection) {
452
478
  connection.onRequest("workspace/configuration", (params) => Array.isArray(params?.items) ? params.items.map(() => null) : []);
453
479
  connection.onRequest("workspace/workspaceFolders", () => [{
@@ -502,53 +528,6 @@ async function workspaceSourcePath(workspaceRoot, inputPath) {
502
528
  throw new CodeIntelligenceError("code.language_service_unavailable", `Unable to read code-intelligence source ${inputPath}: ${errorMessage(error)}`);
503
529
  }
504
530
  }
505
- async function withTimeout(promise, timeoutMs, timeoutError) {
506
- let timer;
507
- try {
508
- return await Promise.race([
509
- promise,
510
- new Promise((_resolve, reject) => {
511
- timer = setTimeout(() => reject(timeoutError()), timeoutMs);
512
- timer.unref();
513
- }),
514
- ]);
515
- }
516
- finally {
517
- if (timer)
518
- clearTimeout(timer);
519
- }
520
- }
521
- async function waitForChildSpawn(child, serverId) {
522
- await new Promise((resolvePromise, rejectPromise) => {
523
- const onSpawn = () => {
524
- child.off("error", onError);
525
- resolvePromise();
526
- };
527
- const onError = (error) => {
528
- child.off("spawn", onSpawn);
529
- rejectPromise(new CodeIntelligenceError("code.language_service_start_failed", `Unable to start Language server ${serverId}: ${error.message}.`));
530
- };
531
- child.once("spawn", onSpawn);
532
- child.once("error", onError);
533
- });
534
- }
535
- async function waitForChildExit(child, timeoutMs) {
536
- if (child.exitCode !== null || child.signalCode !== null)
537
- return true;
538
- return new Promise((resolvePromise) => {
539
- let timer;
540
- const finish = (exited) => {
541
- child.off("exit", onExit);
542
- if (timer)
543
- clearTimeout(timer);
544
- resolvePromise(exited);
545
- };
546
- const onExit = () => finish(true);
547
- child.once("exit", onExit);
548
- timer = setTimeout(() => finish(false), timeoutMs);
549
- timer.unref();
550
- });
551
- }
552
531
  function errorMessage(error) {
553
532
  return error instanceof Error ? error.message : String(error);
554
533
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import { resolve, sep } from "node:path";
4
+ import { diagnosticError } from "../../runtime/logging/runtime-diagnostics.js";
4
5
  import { CodeIntelligenceError, LanguageService, languageServiceKey, } from "../code-intelligence.js";
5
6
  import { LanguageServerConfigurationError, resolveLanguageProject, } from "../language-server-config.js";
6
7
  import { managedLanguageServerIdForCommand, managedLanguageServerRuntimeIdentity, managedTypeScriptTsserverPath, withManagedLanguageServerPath, } from "./managed-language-servers.js";
@@ -24,6 +25,9 @@ export class CodeIntelligenceManager {
24
25
  crashStates = new Map();
25
26
  retiredWorkspaceRoots = new Set();
26
27
  serviceCreationQueue = Promise.resolve();
28
+ activeRuns = 0;
29
+ runDrainWaiters = new Set();
30
+ shutdownPromise;
27
31
  cleanupTimer;
28
32
  policy;
29
33
  crashCooldownMs;
@@ -33,6 +37,7 @@ export class CodeIntelligenceManager {
33
37
  this.projectExecutionTrustPolicy = options.projectExecutionTrustPolicy ?? compatibilityAllowProjectExecutionTrustPolicy;
34
38
  this.crashCooldownMs = positiveInteger(options.crashCooldownMs, LANGUAGE_SERVICE_CRASH_COOLDOWN_MS, "crashCooldownMs");
35
39
  this.policy = {
40
+ diagnostics: options.diagnostics,
36
41
  idleMs: positiveInteger(options.idleMs, LANGUAGE_SERVICE_IDLE_MS, "idleMs"),
37
42
  cleanupIntervalMs: positiveInteger(options.cleanupIntervalMs, LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS, "cleanupIntervalMs"),
38
43
  maxServices: positiveInteger(options.maxServices, MAX_LANGUAGE_SERVICES, "maxServices"),
@@ -50,6 +55,23 @@ export class CodeIntelligenceManager {
50
55
  this.cleanupTimer.unref();
51
56
  }
52
57
  async run(workspaceRoot, input, options = {}) {
58
+ if (this.shutdownPromise) {
59
+ throw new CodeIntelligenceError("code.language_service_unavailable", "Code intelligence is shutting down.");
60
+ }
61
+ this.activeRuns += 1;
62
+ try {
63
+ return await this.runActive(workspaceRoot, input, options);
64
+ }
65
+ finally {
66
+ this.activeRuns = Math.max(0, this.activeRuns - 1);
67
+ if (this.activeRuns === 0) {
68
+ for (const resolveDrain of this.runDrainWaiters)
69
+ resolveDrain();
70
+ this.runDrainWaiters.clear();
71
+ }
72
+ }
73
+ }
74
+ async runActive(workspaceRoot, input, options) {
53
75
  const canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
54
76
  this.assertWorkspaceRootAvailable(canonicalWorkspaceRoot);
55
77
  let project;
@@ -89,6 +111,7 @@ export class CodeIntelligenceManager {
89
111
  return result;
90
112
  }
91
113
  catch (error) {
114
+ service.recordDiagnostic("lsp_operation_failed", { operation: input.operation, ...diagnosticError(error) });
92
115
  if (!(error instanceof CodeIntelligenceError) || error.code !== "code.server_crashed") {
93
116
  throw error;
94
117
  }
@@ -113,7 +136,12 @@ export class CodeIntelligenceManager {
113
136
  throw lastCrash ?? new CodeIntelligenceError("code.server_crashed", `Language server ${project.definition.id} failed without a recoverable result.`);
114
137
  }
115
138
  async shutdown() {
139
+ this.shutdownPromise ??= this.performShutdown();
140
+ return this.shutdownPromise;
141
+ }
142
+ async performShutdown() {
116
143
  clearInterval(this.cleanupTimer);
144
+ await this.waitForActiveRuns();
117
145
  await Promise.allSettled(this.serviceCreations.values());
118
146
  this.serviceCreations.clear();
119
147
  const services = [...this.services.values()];
@@ -121,7 +149,14 @@ export class CodeIntelligenceManager {
121
149
  this.invalidatedServiceKeys.clear();
122
150
  this.crashStates.clear();
123
151
  this.retiredWorkspaceRoots.clear();
124
- await Promise.allSettled(services.map((service) => service.shutdown()));
152
+ await Promise.allSettled(services.map((service) => service.shutdown("server_shutdown")));
153
+ }
154
+ waitForActiveRuns() {
155
+ if (this.activeRuns === 0)
156
+ return Promise.resolve();
157
+ return new Promise((resolveDrain) => {
158
+ this.runDrainWaiters.add(resolveDrain);
159
+ });
125
160
  }
126
161
  get size() {
127
162
  return this.services.size;
@@ -176,7 +211,7 @@ export class CodeIntelligenceManager {
176
211
  this.services.delete(key);
177
212
  this.invalidatedServiceKeys.delete(key);
178
213
  this.crashStates.delete(key);
179
- await service.shutdown();
214
+ await service.shutdown("workspace_retired");
180
215
  releasedServices += 1;
181
216
  }
182
217
  this.clearIdentityStateForWorkspaceRoot(canonicalRoot);
@@ -242,10 +277,11 @@ export class CodeIntelligenceManager {
242
277
  invalidated.push([key, service]);
243
278
  }
244
279
  for (const [key, service] of invalidated) {
245
- if (this.services.get(key) === service)
246
- this.services.delete(key);
280
+ if (this.services.get(key) !== service || !service.isIdle)
281
+ continue;
282
+ this.services.delete(key);
247
283
  this.invalidatedServiceKeys.delete(key);
248
- await service.shutdown();
284
+ await service.shutdown("configuration_unavailable");
249
285
  }
250
286
  }
251
287
  async invalidateChangedServices(project) {
@@ -262,10 +298,11 @@ export class CodeIntelligenceManager {
262
298
  }
263
299
  }
264
300
  for (const [key, service] of invalidated) {
265
- if (this.services.get(key) === service)
266
- this.services.delete(key);
301
+ if (this.services.get(key) !== service || !service.isIdle)
302
+ continue;
303
+ this.services.delete(key);
267
304
  this.invalidatedServiceKeys.delete(key);
268
- await service.shutdown();
305
+ await service.shutdown("configuration_changed");
269
306
  }
270
307
  }
271
308
  async releaseService(service) {
@@ -275,13 +312,13 @@ export class CodeIntelligenceManager {
275
312
  if (this.services.get(service.key) === service)
276
313
  this.services.delete(service.key);
277
314
  this.invalidatedServiceKeys.delete(service.key);
278
- await service.shutdown();
315
+ await service.shutdown("invalidated");
279
316
  }
280
317
  async discardService(service) {
281
318
  if (this.services.get(service.key) === service)
282
319
  this.services.delete(service.key);
283
320
  this.invalidatedServiceKeys.delete(service.key);
284
- await service.shutdown();
321
+ await service.shutdown("crashed");
285
322
  }
286
323
  async acquireService(workspaceRoot, project) {
287
324
  const key = languageServiceKey(project);
@@ -335,8 +372,13 @@ export class CodeIntelligenceManager {
335
372
  async closeIdle(now = Date.now()) {
336
373
  const stale = [...this.services.entries()].filter(([, service]) => service.isIdle && now - service.lastUsedAt >= this.policy.idleMs);
337
374
  for (const [key, service] of stale) {
375
+ if (this.services.get(key) !== service ||
376
+ !service.isIdle ||
377
+ now - service.lastUsedAt < this.policy.idleMs) {
378
+ continue;
379
+ }
338
380
  this.services.delete(key);
339
- await service.shutdown();
381
+ await service.shutdown("idle_timeout");
340
382
  }
341
383
  }
342
384
  async ensureCapacity() {
@@ -350,7 +392,7 @@ export class CodeIntelligenceManager {
350
392
  throw new CodeIntelligenceError("code.language_service_capacity", `Language service capacity reached (${this.policy.maxServices}) with no idle service available for eviction.`);
351
393
  }
352
394
  this.services.delete(candidate[0]);
353
- await candidate[1].shutdown();
395
+ await candidate[1].shutdown("capacity_limit");
354
396
  }
355
397
  }
356
398
  function withManagedLanguageServerRuntime(project, configDir) {
@@ -1,3 +1,51 @@
1
+ import { CodeIntelligenceError } from "../code-intelligence-error.js";
2
+ export async function withTimeout(promise, timeoutMs, timeoutError) {
3
+ let timer;
4
+ try {
5
+ return await Promise.race([
6
+ promise,
7
+ new Promise((_resolve, reject) => {
8
+ timer = setTimeout(() => reject(timeoutError()), timeoutMs);
9
+ timer.unref();
10
+ }),
11
+ ]);
12
+ }
13
+ finally {
14
+ if (timer)
15
+ clearTimeout(timer);
16
+ }
17
+ }
18
+ export async function waitForChildSpawn(child, serverId) {
19
+ await new Promise((resolvePromise, rejectPromise) => {
20
+ const onSpawn = () => {
21
+ child.off("error", onError);
22
+ resolvePromise();
23
+ };
24
+ const onError = (error) => {
25
+ child.off("spawn", onSpawn);
26
+ rejectPromise(new CodeIntelligenceError("code.language_service_start_failed", `Unable to start Language server ${serverId}: ${error.message}.`));
27
+ };
28
+ child.once("spawn", onSpawn);
29
+ child.once("error", onError);
30
+ });
31
+ }
32
+ export async function waitForChildExit(child, timeoutMs) {
33
+ if (child.exitCode !== null || child.signalCode !== null)
34
+ return true;
35
+ return new Promise((resolvePromise) => {
36
+ let timer;
37
+ const finish = (exited) => {
38
+ child.off("exit", onExit);
39
+ if (timer)
40
+ clearTimeout(timer);
41
+ resolvePromise(exited);
42
+ };
43
+ const onExit = () => finish(true);
44
+ child.once("exit", onExit);
45
+ timer = setTimeout(() => finish(false), timeoutMs);
46
+ timer.unref();
47
+ });
48
+ }
1
49
  export function languageServerCommandRequiresShell(command, platform = process.platform) {
2
50
  return platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
3
51
  }
@@ -11,6 +11,7 @@ import { ActivityQueryService } from "../../../activity/history/query-service.js
11
11
  import { CodeIntelligenceManager } from "../../../lsp/runtime/manager.js";
12
12
  import { createOpenAIIncomingArtifactAdapter } from "../../artifacts/incoming-artifacts.js";
13
13
  import { logEvent, requestPath, transportSessionIdPrefix } from "../../../runtime/logging/logger.js";
14
+ import { RuntimeDiagnostics } from "../../../runtime/logging/runtime-diagnostics.js";
14
15
  import { SingleUserOAuthProvider } from "../../oauth/oauth-provider.js";
15
16
  import { createForgeRelayAuthRouter } from "../../oauth/router.js";
16
17
  import { publicEndpointPaths, publicEndpointUrl } from "../../oauth/public-url.js";
@@ -80,7 +81,8 @@ export function createHttpServer(config, options, createMcpServer) {
80
81
  outputAudit: bashOutputStore,
81
82
  commandShellRuntime: config.commandShellRuntime,
82
83
  });
83
- const codeIntelligence = new CodeIntelligenceManager(config);
84
+ const diagnostics = options.runtimeDiagnostics ?? new RuntimeDiagnostics(config.stateDir);
85
+ const codeIntelligence = new CodeIntelligenceManager(config, { diagnostics });
84
86
  const subagentProviders = config.subagents
85
87
  ? getSubagentProviderAvailabilitySnapshot()
86
88
  : [];
@@ -0,0 +1,92 @@
1
+ import { closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ /** Keep error identities and call sites, without persisting messages or server stderr. */
4
+ export function diagnosticError(error) {
5
+ if (!(error instanceof Error))
6
+ return { errorName: "NonError" };
7
+ return {
8
+ errorName: error.name,
9
+ errorCode: "code" in error && typeof error.code === "string" ? error.code : undefined,
10
+ stack: error.stack?.split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 16),
11
+ };
12
+ }
13
+ /** Synchronous writes retain fatal-event evidence even when Node exits immediately afterwards. */
14
+ export class RuntimeDiagnostics {
15
+ maxBytes;
16
+ path;
17
+ warned = false;
18
+ constructor(stateDir, maxBytes = 4 * 1024 * 1024) {
19
+ this.maxBytes = maxBytes;
20
+ this.path = join(stateDir, "logs", "runtime.jsonl");
21
+ }
22
+ record(event, fields = {}) {
23
+ let fd;
24
+ try {
25
+ const bounded = JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, event, ...fields }, (_key, value) => typeof value === "string" ? value.slice(0, 1024) : value);
26
+ const line = Buffer.from(`${bounded}\n`);
27
+ if (line.length > this.maxBytes)
28
+ throw new Error("Diagnostic entry exceeds retention limit.");
29
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
30
+ let size = 0;
31
+ try {
32
+ size = statSync(this.path).size;
33
+ }
34
+ catch (error) {
35
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
36
+ throw error;
37
+ }
38
+ if (size + line.length > this.maxBytes)
39
+ this.rotate();
40
+ fd = openSync(this.path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND |
41
+ (constants.O_NOFOLLOW ?? 0), 0o600);
42
+ if (!fstatSync(fd).isFile())
43
+ throw new Error("Runtime diagnostic destination must be a regular file.");
44
+ fchmodSync(fd, 0o600);
45
+ let offset = 0;
46
+ while (offset < line.length)
47
+ offset += writeSync(fd, line, offset, line.length - offset);
48
+ }
49
+ catch {
50
+ if (!this.warned) {
51
+ this.warned = true;
52
+ console.error("ForgeRelay could not persist runtime diagnostics; check state-directory permissions and disk space.");
53
+ }
54
+ }
55
+ finally {
56
+ if (fd !== undefined) {
57
+ try {
58
+ closeSync(fd);
59
+ }
60
+ catch { /* Logging must never replace the original failure. */ }
61
+ }
62
+ }
63
+ }
64
+ rotate() {
65
+ for (let index = 3; index >= 0; index -= 1) {
66
+ const path = index === 0 ? this.path : `${this.path}.${index}`;
67
+ try {
68
+ if (index === 3)
69
+ unlinkSync(path);
70
+ else
71
+ renameSync(path, `${this.path}.${index + 1}`);
72
+ }
73
+ catch (error) {
74
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
75
+ throw error;
76
+ }
77
+ }
78
+ }
79
+ }
80
+ /** Observes fatal errors without converting an unhandled rejection into a recoverable event. */
81
+ export function observeRuntimeExit(diagnostics) {
82
+ const onFatal = (error, origin) => {
83
+ diagnostics.record("runtime_uncaught_exception", { reason: origin, ...diagnosticError(error) });
84
+ };
85
+ const onExit = (exitCode) => diagnostics.record("runtime_exit", { exitCode });
86
+ process.on("uncaughtExceptionMonitor", onFatal);
87
+ process.on("exit", onExit);
88
+ return () => {
89
+ process.off("uncaughtExceptionMonitor", onFatal);
90
+ process.off("exit", onExit);
91
+ };
92
+ }
@@ -87,7 +87,7 @@ export async function fixture(t, options = {}) {
87
87
  turnId: () => `turn_test_${++turnSequence}`,
88
88
  turnIdForConversation: (conversationScopeId, workspaceId) => activityQueries.currentTurnId(conversationScopeId, workspaceId),
89
89
  });
90
- const codeIntelligence = new CodeIntelligenceManager(config);
90
+ const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
91
91
  const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], options.incomingArtifactAdapters ?? [], codeIntelligence, activityLifecycle, bashOutputStore, activityQueries);
92
92
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
93
93
  const client = new Client({ name: "forgerelay-test-client", version: "1.0.0" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "1.3.5",
3
+ "version": "1.3.6-beta.1",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -68,7 +68,7 @@
68
68
  "release:push-ready": "node scripts/release/push-ready.mjs",
69
69
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
70
70
  "start": "node dist/cli.js serve",
71
- "test": "node --test scripts/debug/runtime.test.mjs scripts/config/schema-text.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/workspaces/relay/tests/composite.test.ts && tsx src/runtime/config/definition/schema.test.ts && tsx src/runtime/config/resolution/resolver.test.ts && tsx src/runtime/security/project-execution-trust.test.ts && tsx src/runtime/config/resolution/hooks.test.ts && tsx src/workspaces/state/project-context.test.ts && tsx src/runtime/config/config.test.ts && tsx src/cli/config/general.test.ts && tsx src/cli/config/domains/domain-cli.test.ts && node --test --import tsx src/cli/config/domains/context-cli.test.ts && node --test --import tsx src/cli/config/inspect.test.ts src/cli/config/migrate.test.ts && tsx src/runtime/config/external-mcp-registry.test.ts && tsx src/runtime/config/external-mcp-auth-store.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-oauth.test.ts && tsx src/cli/mcp/status.test.ts && tsx src/cli/mcp/diagnostics.test.ts && tsx src/cli/mcp/external-mcp.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/operations/project-trust.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/cli/system/status.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/hooks/hooks-trust.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-transform.test.ts && tsx src/mcp/hooks/external-mcp-transform-trust.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-trust.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-context.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/project-trust.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
71
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/config/schema-text.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/workspaces/relay/tests/composite.test.ts && tsx src/runtime/config/definition/schema.test.ts && tsx src/runtime/config/resolution/resolver.test.ts && tsx src/runtime/security/project-execution-trust.test.ts && tsx src/runtime/config/resolution/hooks.test.ts && tsx src/workspaces/state/project-context.test.ts && tsx src/runtime/config/config.test.ts && tsx src/cli/config/general.test.ts && tsx src/cli/config/domains/domain-cli.test.ts && node --test --import tsx src/cli/config/domains/context-cli.test.ts && node --test --import tsx src/cli/config/inspect.test.ts src/cli/config/migrate.test.ts && tsx src/runtime/config/external-mcp-registry.test.ts && tsx src/runtime/config/external-mcp-auth-store.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-oauth.test.ts && tsx src/cli/mcp/status.test.ts && tsx src/cli/mcp/diagnostics.test.ts && tsx src/cli/mcp/external-mcp.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/operations/project-trust.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/runtime-diagnostics.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/cli/system/status.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/hooks/hooks-trust.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-transform.test.ts && tsx src/mcp/hooks/external-mcp-transform-trust.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-trust.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-context.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/mutation-diagnostics-stress.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/project-trust.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
72
72
  "typecheck": "tsc -p tsconfig.json --noEmit",
73
73
  "release:check": "node scripts/release-version.mjs check",
74
74
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
4
+ cd "$repo_root"
5
+ mkdir -p logs
6
+ node --input-type=module <<'JS' 2>&1 | tee logs/lsp-beta-dev.log
7
+ import assert from "node:assert/strict";
8
+ import { spawn } from "node:child_process";
9
+ import { relative, resolve, sep } from "node:path";
10
+ import { createInteractiveDebugEnvironment, debugRoot } from "./scripts/debug/runtime.mjs";
11
+ import { loadConfig } from "./dist/runtime/config/config.js";
12
+
13
+ const { configDir, env } = createInteractiveDebugEnvironment();
14
+ const config = loadConfig(env);
15
+ assert.equal(config.port, 7677, "Beta acceptance must use the dedicated dev port 7677.");
16
+ assert.equal(config.host, "127.0.0.1", "Beta acceptance must bind to loopback.");
17
+ const stateRelative = relative(resolve(debugRoot), resolve(config.stateDir));
18
+ assert.ok(stateRelative !== ".." && !stateRelative.startsWith(`..${sep}`) && !stateRelative.startsWith(sep),
19
+ "Beta acceptance state must stay under .forgerelay-debug/.");
20
+ console.error(`[forgerelay:beta] config: ${configDir}; isolated state: ${config.stateDir}`);
21
+ const child = spawn(process.execPath, ["dist/cli.js", "serve"], { env, stdio: "inherit" });
22
+ for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
23
+ child.on("error", (error) => { console.error(error); process.exitCode = 1; });
24
+ child.on("exit", (code, signal) => {
25
+ process.exitCode = code ?? (signal ? 1 : 0);
26
+ });
27
+ JS
@@ -113,7 +113,8 @@ for (const fixture of fixtures) {
113
113
  console.log(`SKIP ${fixture.id}: ${fixture.command} not found on PATH; ForgeRelay does not install Language servers.`);
114
114
  continue;
115
115
  }
116
- const preflight = probeExecutable(executable);
116
+ // pyright-langserver has no --version mode; verify it with the real LSP requests below.
117
+ const preflight = fixture.id === "pyright" ? { available: true } : probeExecutable(executable);
117
118
  if (!preflight.available) {
118
119
  skipped += 1;
119
120
  console.log(`SKIP ${fixture.id}: ${executable} is present but not runnable: ${preflight.reason}`);
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
4
+ cd "$repo_root"
5
+ mkdir -p logs
6
+ node --import tsx scripts/lsp/mutation-soak.ts 2>&1 | tee logs/lsp-mutation-http-soak.log
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import { once } from "node:events";
5
+ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join, resolve } from "node:path";
8
+ import { promisify } from "node:util";
9
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
10
+ import { createMcpServer } from "../../src/server.js";
11
+ import { createHttpServer } from "../../src/mcp/server/transport/http-server.js";
12
+ import { loadConfig } from "../../src/runtime/config/config.js";
13
+ import type { CodeIntelligenceManager } from "../../src/lsp/runtime/manager.js";
14
+
15
+ // A disposable dev HTTP instance; never reuse installed configuration or state.
16
+ const base = "http://127.0.0.1:7677";
17
+ const debugRoot = resolve(".forgerelay-debug");
18
+ await mkdir(debugRoot, { recursive: true });
19
+ const root = await mkdtemp(join(debugRoot, "mutation-soak-"));
20
+ const configDir = join(root, "config");
21
+ const project = join(root, "project");
22
+ await mkdir(configDir);
23
+ await mkdir(join(project, ".forgerelay"), { recursive: true });
24
+ // Prevent discovery from inheriting the enclosing ForgeRelay Git project.
25
+ await promisify(execFile)("git", ["init", "--quiet", project]);
26
+ await writeFile(join(configDir, "config.json"), JSON.stringify({
27
+ host: "127.0.0.1", port: 7677, publicBaseUrl: base,
28
+ allowedRoots: [root], stateDir: join(root, "state"), worktreeRoot: join(root, "worktrees"),
29
+ agentDir: join(root, "agent"), subagents: false,
30
+ }));
31
+ const ownerToken = randomBytes(32).toString("base64url");
32
+ const config = loadConfig({
33
+ PATH: process.env.PATH,
34
+ FORGERELAY_CONFIG_DIR: configDir,
35
+ FORGERELAY_OAUTH_OWNER_TOKEN: ownerToken,
36
+ FORGERELAY_TOOL_MODE: "full",
37
+ FORGERELAY_WIDGETS: "off",
38
+ FORGERELAY_SKILLS: "0",
39
+ FORGERELAY_LOG_SHELL_COMMANDS: "0",
40
+ });
41
+ assert.equal(config.port, 7677);
42
+ assert.equal(config.stateDir, join(root, "state"));
43
+ let manager: CodeIntelligenceManager | undefined;
44
+ const diagnostics = new Map<string, number>();
45
+ const runtime = createHttpServer(config, {}, (...args) => {
46
+ if (!manager) {
47
+ manager = args[6];
48
+ const run = manager.run.bind(manager);
49
+ manager.run = async (...input) => {
50
+ const result = await run(...input);
51
+ if (result.operation === "diagnostics") {
52
+ diagnostics.set(result.selectedServer, (diagnostics.get(result.selectedServer) ?? 0) + 1);
53
+ }
54
+ return result;
55
+ };
56
+ }
57
+ return createMcpServer(...args);
58
+ });
59
+ const http = runtime.app.listen(7677, "127.0.0.1");
60
+ // EADDRINUSE fails the run; this script never stops an existing listener.
61
+ await once(http, "listening");
62
+ const client = new Client({ name: "mutation-soak", version: "1" }, {
63
+ versionNegotiation: { mode: "legacy" },
64
+ });
65
+ type ToolInput =
66
+ | { path: string; context?: "full" }
67
+ | { workspaceId: string; path: string; content: string }
68
+ | { workspaceId: string; path: string; edits: Array<{ oldText: string; newText: string }> };
69
+ let mutations = 0;
70
+ try {
71
+ const auth = await fetch(`${base}/auth/cli`, {
72
+ method: "POST", headers: { "content-type": "application/json" },
73
+ body: JSON.stringify({ owner_token: ownerToken }),
74
+ });
75
+ assert.equal(auth.status, 200);
76
+ const tokens = await auth.json() as { access_token: string };
77
+ await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), {
78
+ requestInit: { headers: { Authorization: `Bearer ${tokens.access_token}` } },
79
+ }));
80
+ const call = async (name: string, input: ToolInput, signal?: AbortSignal) => {
81
+ const result = await client.callTool({
82
+ name, arguments: input, _meta: { "openai/session": "mutation-soak" },
83
+ }, { signal, timeout: 30_000 });
84
+ assert.notEqual(result.isError, true, `${name} failed`);
85
+ return result;
86
+ };
87
+ const opened = await call("open_workspace", { path: project, context: "full" });
88
+ const workspaceId = (opened.structuredContent as { workspaceId: string }).workspaceId;
89
+ assert.ok(manager);
90
+ const cleanup = () => (manager as unknown as { closeIdle(now: number): Promise<void> })
91
+ .closeIdle(Date.now() + 3_600_000);
92
+ const pyright = join(homedir(), ".forgerelay/language-servers/node_modules/.bin/pyright-langserver");
93
+ const fake = resolve("src/lsp/test-fixtures/fake-lsp-server.mjs");
94
+ const cancellationLog = join(root, "cancellation-lsp.jsonl");
95
+ const languageConfig = (generation: number) => ({
96
+ pyright: {
97
+ command: pyright, args: ["--stdio"],
98
+ languages: ["python"], extensions: [".py"], projectMarkers: ["pyrightconfig.json"],
99
+ env: { FORGERELAY_SOAK_GENERATION: String(generation) },
100
+ },
101
+ capacity: {
102
+ command: process.execPath, args: [fake],
103
+ languages: ["typescript"], extensions: [".cap"], projectMarkers: ["capacity.project"],
104
+ env: { FORGERELAY_FAKE_LSP_DIAGNOSTICS_MODE: "pull" },
105
+ },
106
+ cancellation: {
107
+ command: process.execPath, args: [fake],
108
+ languages: ["typescript"], extensions: [".cancel"], projectMarkers: [],
109
+ env: {
110
+ FORGERELAY_FAKE_LSP_DIAGNOSTICS_MODE: "pull",
111
+ FORGERELAY_FAKE_LSP_DIAGNOSTIC_DELAY_MS: "1000",
112
+ FORGERELAY_FAKE_LSP_LOG: cancellationLog,
113
+ },
114
+ },
115
+ });
116
+ const definitions = join(project, ".forgerelay/language-servers.json");
117
+ await writeFile(definitions, JSON.stringify(languageConfig(0)));
118
+ for (let i = 0; i < 3; i++) {
119
+ await mkdir(join(project, `python-${i}`));
120
+ await writeFile(join(project, `python-${i}/pyrightconfig.json`), "{}");
121
+ }
122
+ const verify = async (result: Awaited<ReturnType<typeof call>>, diagnostic: RegExp) => {
123
+ const text = result.content.filter(c => c.type === "text").map(c => c.text).join("\n");
124
+ if (diagnostic.source !== "pyright") assert.match(text, diagnostic);
125
+ assert.doesNotMatch(text, /diagnostics warning|SIGTERM|start_failed|server_crashed|failed internally/i);
126
+ mutations++;
127
+ };
128
+ for (let round = 0; round < 30; round++) {
129
+ await Promise.all([0, 1, 2].map(async i => {
130
+ const path = `python-${i}/main.py`;
131
+ const oldText = `value: int = "round-${round}"`;
132
+ await verify(await call("write", { workspaceId, path, content: `${oldText}\n` }), /pyright/);
133
+ await verify(await call("edit", {
134
+ workspaceId, path, edits: [{ oldText, newText: `value: int = "edited-${round}"` }],
135
+ }), /pyright/);
136
+ }));
137
+ if (round % 5 === 4) {
138
+ await writeFile(definitions, JSON.stringify(languageConfig(round)));
139
+ await cleanup();
140
+ }
141
+ assert.equal(diagnostics.get("pyright"), mutations, "each Python mutation must finish real diagnostics");
142
+ if (round % 10 === 9) console.log(`PASS real Pyright: ${round + 1} rounds, ${mutations} mutations`);
143
+ }
144
+ assert.equal(diagnostics.get("pyright"), 180, "every Python mutation must complete real Pyright diagnostics");
145
+ // Default capacity is sixteen services. Distinct project roots force eviction.
146
+ for (let i = 0; i < 18; i++) {
147
+ await mkdir(join(project, `capacity-${i}`));
148
+ await writeFile(join(project, `capacity-${i}/capacity.project`), "");
149
+ await verify(await call("write", {
150
+ workspaceId, path: `capacity-${i}/main.cap`, content: "const value = 1;\n",
151
+ }), /pulled diagnostic/);
152
+ }
153
+ assert.ok(manager.stats().servicesTotal <= 16);
154
+ assert.equal(diagnostics.get("capacity"), 18);
155
+ await verify(await call("write", {
156
+ workspaceId, path: "main.cancel", content: "const value = 1;\n",
157
+ }), /pulled diagnostic/);
158
+ const controller = new AbortController();
159
+ const pendingEdit = call("edit", {
160
+ workspaceId, path: "main.cancel", edits: [{ oldText: "1", newText: "2" }],
161
+ }, controller.signal);
162
+ const cancelled = assert.rejects(pendingEdit, /abort|cancel/i);
163
+ const deadline = Date.now() + 10_000;
164
+ while (true) {
165
+ const log = await readFile(cancellationLog, "utf8");
166
+ if (log.split('"method":"textDocument/diagnostic"').length >= 3) break;
167
+ assert.ok(Date.now() < deadline, "cancelled edit must reach automatic diagnostics");
168
+ await new Promise(resolveWait => setTimeout(resolveWait, 10));
169
+ }
170
+ controller.abort();
171
+ await cancelled;
172
+ assert.match(await readFile(join(project, "main.cancel"), "utf8"), /value = 2/);
173
+ await verify(await call("edit", {
174
+ workspaceId, path: "main.cancel", edits: [{ oldText: "2", newText: "3" }],
175
+ }), /pulled diagnostic/);
176
+ await cleanup();
177
+ assert.equal(manager.stats().servicesTotal, 0);
178
+ const health = await fetch(`${base}/healthz`);
179
+ assert.equal(health.status, 200);
180
+ console.log(`PASS dev HTTP/MCP soak: ${mutations} verified mutations + cancellation; state ${root}`);
181
+ await writeFile(join(root, "result.json"), JSON.stringify({ mutations, passed: true }));
182
+ } finally {
183
+ await client.close();
184
+ await runtime.close();
185
+ await new Promise<void>((resolveClose, reject) => http.close(error => error ? reject(error) : resolveClose()));
186
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
4
+ cd "$repo_root"
5
+ mkdir -p logs
6
+ npm run release:verify 2>&1 | tee logs/beta-release-verify.log
@@ -40,7 +40,7 @@ if (basename(packagePath) !== expectedPackageName) {
40
40
  throw new Error(`unexpected release artifact ${basename(packagePath)}; expected ${expectedPackageName}`);
41
41
  }
42
42
 
43
- const npmTag = pkg.version.includes("-rc.") ? "next" : "latest";
43
+ const npmTag = /-(?:rc|beta)\.\d+$/.test(pkg.version) ? "next" : "latest";
44
44
  const packageSpec = `${pkg.name}@${pkg.version}`;
45
45
  if (npmPackageExists(packageSpec)) {
46
46
  console.log(`${packageSpec} is already published; leaving npm unchanged.`);
@@ -110,10 +110,22 @@ test("release version tooling prepares and validates an rc release", async (t) =
110
110
  test("release version tooling rejects unsupported prerelease identifiers", async (t) => {
111
111
  const root = await createFixture(t);
112
112
  await assert.rejects(
113
- () => runRelease(root, "prepare", "0.6.0-beta.1"),
113
+ () => runRelease(root, "prepare", "0.6.0-alpha.1"),
114
114
  (error) => {
115
- assert.match(String(error.stderr ?? error), /prepare X\.Y\.Z\[-rc\.N\]/);
115
+ assert.match(String(error.stderr ?? error), /prepare X\.Y\.Z\[-rc\.N\|-beta\.N\]/);
116
116
  return true;
117
117
  },
118
118
  );
119
119
  });
120
+
121
+ test("beta metadata and matching tag require dedicated beta notes", async (t) => {
122
+ const root = await createFixture(t);
123
+ await runRelease(root, "prepare", "0.5.8-beta.1");
124
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
125
+ assert.equal(pkg.version, "0.5.8-beta.1");
126
+ await runRelease(root, "check");
127
+ await assert.rejects(() => runRelease(root, "tag", "v0.5.8-beta.1"), /missing dedicated release notes/);
128
+ await writeFile(join(root, "docs", "releases", "v0.5.8-beta.1.md"), "# Beta\n\nBeta notes.\n");
129
+ const checked = await runRelease(root, "tag", "v0.5.8-beta.1");
130
+ assert.match(checked.stdout, /release tag v0\.5\.8-beta\.1 matches/);
131
+ });
@@ -135,11 +135,12 @@ function hookTag() {
135
135
  }
136
136
  const pushCommand = pushMatch[0];
137
137
  if (/(?:^|\s)(?:-f|--force(?:-with-lease)?)(?:=\S*)?(?=$|\s)/.test(pushCommand)
138
- || /(?:^|\s)\+(?:refs\/tags\/)?v\d+\.\d+\.\d+(?:-rc\.\d+)?(?=$|\s)/.test(pushCommand)) {
138
+ || /(?:^|\s)\+(?:refs\/tags\/)?v\d+\.\d+\.\d+(?:-(?:rc|beta)\.\d+)?(?=$|\s)/.test(pushCommand)) {
139
139
  throw new Error("force push is not allowed for release tags");
140
140
  }
141
141
  const deleting = /(?:^|\s)(?:-d|--delete)(?=$|\s)/.test(pushCommand);
142
- const tagMatch = /(?:^|\s)(?:tag\s+)?(?:refs\/tags\/)?(v\d+\.\d+\.\d+(?:-rc\.\d+)?)(?=$|\s)/.exec(pushCommand);
142
+ const tagPattern = /(?:^|\s)(?:tag\s+)?(?:refs\/tags\/)?(v\d+\.\d+\.\d+(?:-(?:rc|beta)\.\d+)?)(?=$|\s)/;
143
+ const tagMatch = tagPattern.exec(pushCommand);
143
144
  if (!tagMatch?.[1]) {
144
145
  throw new Error("release Hook payload does not contain a release tag push");
145
146
  }
@@ -231,6 +231,27 @@ test("release tag hook gate accepts an rc tag for the package version", async (t
231
231
  assert.match(checked.stdout, /Release tag gate OK: v0\.6\.0-rc\.1/);
232
232
  });
233
233
 
234
+ test("release tag hook validates beta tags instead of treating them as non-release pushes", async (t) => {
235
+ const root = await mkdtemp(join(tmpdir(), "forgerelay-beta-proof-"));
236
+ t.after(() => rm(root, { recursive: true, force: true }));
237
+ await writeFile(join(root, "package.json"), JSON.stringify({ version: "1.3.6-beta.1" }) + "\n");
238
+ await mkdir(join(root, "docs", "releases"), { recursive: true });
239
+ await writeFile(join(root, "docs", "releases", "v1.3.6-beta.1.md"), "# Beta\n\nDedicated beta notes.\n");
240
+ await git(root, ["init"]);
241
+ await git(root, ["config", "user.email", "proof@example.com"]);
242
+ await git(root, ["config", "user.name", "Release Proof Test"]);
243
+ await git(root, ["add", "."]);
244
+ await git(root, ["commit", "-m", "release beta"]);
245
+ await git(root, ["tag", "v1.3.6-beta.1"]);
246
+ const checked = await runProof(root, "check-hook", {
247
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.3.6-beta.1" }),
248
+ });
249
+ assert.match(checked.stdout, /Release tag gate OK: v1\.3\.6-beta\.1/);
250
+ await assert.rejects(() => runProof(root, "check-hook", {
251
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin +v1.3.6-beta.1" }),
252
+ }), /force push is not allowed/);
253
+ });
254
+
234
255
  test("release tag hook gate rejects a mismatched tag without invoking a remote", async (t) => {
235
256
  const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
236
257
  t.after(() => rm(root, { recursive: true, force: true }));
@@ -9,7 +9,7 @@ const changelogPath = resolve(repoRoot, "CHANGELOG.md");
9
9
  const noticePath = resolve(repoRoot, "NOTICE.md");
10
10
  const licensePath = resolve(repoRoot, "LICENSE");
11
11
  const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
12
- const releaseVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-rc\.(0|[1-9]\d*))?$/;
12
+ const releaseVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:rc|beta)\.(0|[1-9]\d*))?$/;
13
13
  const expectedPackageName = "@akira-tl/forgerelay";
14
14
  const expectedRepositoryUrl = "git+https://github.com/Akira-TL/forgerelay.git";
15
15
  const expectedHomepage = "https://github.com/Akira-TL/forgerelay#readme";
@@ -27,7 +27,7 @@ switch (command) {
27
27
  break;
28
28
  case "tag": {
29
29
  checkState(state);
30
- if (!value) fail("usage: node scripts/release-version.mjs tag vX.Y.Z[-rc.N]");
30
+ if (!value) fail("usage: node scripts/release-version.mjs tag vX.Y.Z[-rc.N|-beta.N]");
31
31
  if (getUnreleasedBody(state.changelog)) {
32
32
  fail("CHANGELOG.md still has Unreleased changes; prepare the next version before creating a release tag");
33
33
  }
@@ -42,7 +42,7 @@ switch (command) {
42
42
  case "prepare": {
43
43
  checkState(state);
44
44
  if (!value || !releaseVersionPattern.test(value)) {
45
- fail("usage: node scripts/release-version.mjs prepare X.Y.Z[-rc.N] [--dry-run]");
45
+ fail("usage: node scripts/release-version.mjs prepare X.Y.Z[-rc.N|-beta.N] [--dry-run]");
46
46
  }
47
47
  await prepareRelease(state, value, dryRun);
48
48
  break;
@@ -58,10 +58,10 @@ switch (command) {
58
58
  }
59
59
  case "notes": {
60
60
  checkState(state);
61
- if (!value) fail("usage: node scripts/release-version.mjs notes vX.Y.Z[-rc.N]");
61
+ if (!value) fail("usage: node scripts/release-version.mjs notes vX.Y.Z[-rc.N|-beta.N]");
62
62
  const version = value.startsWith("v") ? value.slice(1) : value;
63
63
  if (!releaseVersionPattern.test(version)) {
64
- fail(`release notes version ${JSON.stringify(value)} must be vX.Y.Z[-rc.N] or X.Y.Z[-rc.N]`);
64
+ fail(`release notes version ${JSON.stringify(value)} must be vX.Y.Z[-rc.N|-beta.N] or X.Y.Z[-rc.N|-beta.N]`);
65
65
  }
66
66
  const body = getReleaseBody(state.changelog, version);
67
67
  if (!body) fail(`CHANGELOG.md has no release notes for ${version}`);
@@ -98,7 +98,7 @@ function checkState(state) {
98
98
  fail(`unexpected package name ${JSON.stringify(state.pkg.name)}; expected ${expectedPackageName}`);
99
99
  }
100
100
  if (typeof version !== "string" || !releaseVersionPattern.test(version)) {
101
- fail(`package version ${JSON.stringify(version)} must be X.Y.Z or X.Y.Z-rc.N`);
101
+ fail(`package version ${JSON.stringify(version)} must be X.Y.Z, X.Y.Z-rc.N or X.Y.Z-beta.N`);
102
102
  }
103
103
  if (state.pkg.repository?.url !== expectedRepositoryUrl) {
104
104
  fail(`package repository.url must be ${expectedRepositoryUrl} for GitHub/npm trusted publishing`);