@skillcap/gdh 0.16.0 → 0.17.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.
Files changed (53) hide show
  1. package/INSTALL-BUNDLE.json +1 -1
  2. package/RELEASE-SPAN-UPDATE-CONTRACTS.json +87 -0
  3. package/node_modules/@gdh/adapters/dist/index.d.ts +2 -2
  4. package/node_modules/@gdh/adapters/dist/index.d.ts.map +1 -1
  5. package/node_modules/@gdh/adapters/dist/index.js +143 -110
  6. package/node_modules/@gdh/adapters/dist/index.js.map +1 -1
  7. package/node_modules/@gdh/adapters/package.json +8 -8
  8. package/node_modules/@gdh/authoring/dist/index.d.ts +4 -3
  9. package/node_modules/@gdh/authoring/dist/index.d.ts.map +1 -1
  10. package/node_modules/@gdh/authoring/dist/index.js +80 -9
  11. package/node_modules/@gdh/authoring/dist/index.js.map +1 -1
  12. package/node_modules/@gdh/authoring/dist/lsp-client.d.ts +47 -0
  13. package/node_modules/@gdh/authoring/dist/lsp-client.d.ts.map +1 -0
  14. package/node_modules/@gdh/authoring/dist/lsp-client.js +371 -0
  15. package/node_modules/@gdh/authoring/dist/lsp-client.js.map +1 -0
  16. package/node_modules/@gdh/authoring/dist/lsp-test-server.test-utils.d.ts +35 -0
  17. package/node_modules/@gdh/authoring/dist/lsp-test-server.test-utils.d.ts.map +1 -0
  18. package/node_modules/@gdh/authoring/dist/lsp-test-server.test-utils.js +194 -0
  19. package/node_modules/@gdh/authoring/dist/lsp-test-server.test-utils.js.map +1 -0
  20. package/node_modules/@gdh/authoring/dist/lsp.d.ts +62 -1
  21. package/node_modules/@gdh/authoring/dist/lsp.d.ts.map +1 -1
  22. package/node_modules/@gdh/authoring/dist/lsp.js +1207 -109
  23. package/node_modules/@gdh/authoring/dist/lsp.js.map +1 -1
  24. package/node_modules/@gdh/authoring/dist/scene-resource.d.ts +39 -0
  25. package/node_modules/@gdh/authoring/dist/scene-resource.d.ts.map +1 -0
  26. package/node_modules/@gdh/authoring/dist/scene-resource.js +544 -0
  27. package/node_modules/@gdh/authoring/dist/scene-resource.js.map +1 -0
  28. package/node_modules/@gdh/authoring/package.json +2 -2
  29. package/node_modules/@gdh/cli/dist/index.d.ts.map +1 -1
  30. package/node_modules/@gdh/cli/dist/index.js +116 -18
  31. package/node_modules/@gdh/cli/dist/index.js.map +1 -1
  32. package/node_modules/@gdh/cli/package.json +10 -10
  33. package/node_modules/@gdh/core/dist/index.d.ts +48 -13
  34. package/node_modules/@gdh/core/dist/index.d.ts.map +1 -1
  35. package/node_modules/@gdh/core/dist/index.js +14 -17
  36. package/node_modules/@gdh/core/dist/index.js.map +1 -1
  37. package/node_modules/@gdh/core/package.json +1 -1
  38. package/node_modules/@gdh/docs/dist/guidance.d.ts.map +1 -1
  39. package/node_modules/@gdh/docs/dist/guidance.js +12 -2
  40. package/node_modules/@gdh/docs/dist/guidance.js.map +1 -1
  41. package/node_modules/@gdh/docs/dist/rules.d.ts.map +1 -1
  42. package/node_modules/@gdh/docs/dist/rules.js +2 -2
  43. package/node_modules/@gdh/docs/dist/rules.js.map +1 -1
  44. package/node_modules/@gdh/docs/package.json +2 -2
  45. package/node_modules/@gdh/mcp/package.json +8 -8
  46. package/node_modules/@gdh/observability/package.json +2 -2
  47. package/node_modules/@gdh/runtime/package.json +2 -2
  48. package/node_modules/@gdh/scan/package.json +3 -3
  49. package/node_modules/@gdh/verify/dist/policy.d.ts.map +1 -1
  50. package/node_modules/@gdh/verify/dist/policy.js +157 -29
  51. package/node_modules/@gdh/verify/dist/policy.js.map +1 -1
  52. package/node_modules/@gdh/verify/package.json +7 -7
  53. package/package.json +11 -11
@@ -1,12 +1,37 @@
1
- import { spawn } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
+ import { realpathSync } from "node:fs";
3
4
  import fs from "node:fs/promises";
4
5
  import net from "node:net";
5
6
  import path from "node:path";
6
- import { resolveConfiguredGodotEditorBin } from "@gdh/core";
7
- const LSP_INSTANCE_SCHEMA_VERSION = 1;
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { GDH_MANAGED_LSP_SURFACE_VERSION, resolveConfiguredGodotEditorBin } from "@gdh/core";
9
+ import { connectGodotLspClient, GdhLspClientError, } from "./lsp-client.js";
10
+ const LSP_INSTANCE_SCHEMA_VERSION = 2;
8
11
  const LSP_LEASE_SCHEMA_VERSION = 1;
12
+ const LSP_STATE_LOCK_SCHEMA_VERSION = 1;
9
13
  const LSP_LEASE_STALE_AFTER_MS = 5 * 60 * 1000;
14
+ const LSP_STATE_LOCK_STALE_AFTER_MS = 30_000;
15
+ const LSP_STATE_LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
16
+ const LSP_STATE_LOCK_RETRY_DELAY_MS = 25;
17
+ const LSP_INSTANCE_IDLE_AFTER_MS = 30 * 60 * 1000;
18
+ const MANAGED_LSP_HEALTH_TOTAL_TIMEOUT_MS = 5_000;
19
+ const MANAGED_LSP_HEALTH_CONNECT_TIMEOUT_MS = 250;
20
+ const MANAGED_LSP_HEALTH_REQUEST_TIMEOUT_MS = 500;
21
+ const MANAGED_LSP_HEALTH_RETRY_DELAY_MS = 100;
22
+ const MANAGED_LSP_DIAGNOSTIC_CONNECT_TIMEOUT_MS = 1_000;
23
+ const MANAGED_LSP_DIAGNOSTIC_REQUEST_TIMEOUT_MS = 1_000;
24
+ const MANAGED_LSP_DIAGNOSTIC_WAIT_TIMEOUT_MS = 10_000;
25
+ class GdhManagedLspStateLockError extends Error {
26
+ reason;
27
+ reasons;
28
+ constructor(reason, reasons) {
29
+ super(`Managed LSP state lock unavailable: ${reason}`);
30
+ this.name = "GdhManagedLspStateLockError";
31
+ this.reason = reason;
32
+ this.reasons = reasons;
33
+ }
34
+ }
10
35
  const testFakeRegistry = new Map();
11
36
  export async function getManagedLspStatus(input) {
12
37
  const worktree = resolveWorktreeIdentity(input.targetPath, input.status, input.projectConfig);
@@ -29,48 +54,125 @@ export async function getManagedLspStatus(input) {
29
54
  if (lspCapability?.availability === "disabled") {
30
55
  return unavailable;
31
56
  }
32
- return withLease(worktree, input.owner ?? "gdh lsp status", async (lease, activeLeaseCount) => {
33
- let cleanedStaleInstance = false;
34
- const existing = await readPersistedInstance(worktree);
35
- if (existing !== null) {
36
- const validation = await validatePersistedInstance(existing.instance);
37
- if (validation.reusable) {
38
- const reusedInstance = {
39
- ...existing.instance,
40
- lastValidatedAt: new Date().toISOString(),
41
- trust: validation.trust,
42
- };
43
- await persistInstance(worktree, {
44
- ...existing,
45
- instance: reusedInstance,
46
- rawPayload: validation.rawPayload ?? existing.rawPayload,
57
+ try {
58
+ return await withManagedLspStateLock(worktree, input.owner ?? "gdh lsp status", async (stateLock) => await withLease(worktree, input.owner ?? "gdh lsp status", async (lease, activeLeaseCount) => {
59
+ let cleanedStaleInstance = false;
60
+ let cleanupReasons = [];
61
+ const projectPath = path.resolve(input.targetPath, input.status.primaryProjectPath ?? ".");
62
+ const expectedIdentity = await resolveCurrentManagedLspIdentity(worktree, projectPath);
63
+ const existing = await readPersistedInstance(worktree);
64
+ if (existing !== null) {
65
+ const validation = await validatePersistedInstance(existing.instance, expectedIdentity);
66
+ if (validation.reusable) {
67
+ const reusedInstance = {
68
+ ...existing.instance,
69
+ lastValidatedAt: new Date().toISOString(),
70
+ trust: validation.trust,
71
+ };
72
+ await persistInstance(worktree, {
73
+ ...existing,
74
+ instance: reusedInstance,
75
+ rawPayload: validation.rawPayload ?? existing.rawPayload,
76
+ });
77
+ return createStatusResult({
78
+ targetPath: input.targetPath,
79
+ readiness: input.status.readiness,
80
+ availability: "available",
81
+ status: validation.trust === "trusted" ? "ready" : "degraded",
82
+ validationMode: existing.validationMode,
83
+ summary: validation.trust === "trusted"
84
+ ? "Reused the managed authoring.lsp instance for this worktree."
85
+ : "Reused the managed authoring.lsp instance, but trust is degraded and the instance should be treated cautiously.",
86
+ reasons: dedupe([
87
+ ...stateLock.recoveredReasons,
88
+ ...(validation.trust === "trusted" ? [] : ["lsp_instance_degraded"]),
89
+ ]),
90
+ worktree,
91
+ lease,
92
+ activeLeaseCount,
93
+ reusedInstance: true,
94
+ cleanedStaleInstance,
95
+ instance: reusedInstance,
96
+ inventoryObservedAt: input.status.inventoryObservedAt,
97
+ projectConfigPresent: input.projectConfig !== null,
98
+ rawPayload: validation.rawPayload ?? existing.rawPayload,
99
+ });
100
+ }
101
+ cleanedStaleInstance = true;
102
+ cleanupReasons = validation.reasons;
103
+ await cleanupPersistedInstance(worktree, existing.instance, validation.reason, {
104
+ expectedIdentity,
47
105
  });
48
- return createStatusResult({
106
+ }
107
+ const launchResult = await launchManagedInstance(worktree, projectPath);
108
+ if ("unavailable" in launchResult) {
109
+ return createUnavailableStatus({
49
110
  targetPath: input.targetPath,
50
111
  readiness: input.status.readiness,
51
- availability: "available",
52
- status: validation.trust === "trusted" ? "ready" : "degraded",
53
- validationMode: existing.validationMode,
54
- summary: validation.trust === "trusted"
55
- ? "Reused the managed authoring.lsp instance for this worktree."
56
- : "Reused the managed authoring.lsp instance, but trust is degraded and the instance should be treated cautiously.",
57
- reasons: validation.trust === "trusted" ? [] : ["lsp_instance_degraded"],
58
112
  worktree,
113
+ inventoryObservedAt: input.status.inventoryObservedAt,
114
+ projectConfigPresent: input.projectConfig !== null,
115
+ availability: "unavailable",
116
+ reasons: [
117
+ ...stateLock.recoveredReasons,
118
+ ...(cleanedStaleInstance ? ["stale_lsp_instance_cleaned"] : []),
119
+ ...cleanupReasons,
120
+ ...launchResult.unavailable.reasons,
121
+ ],
122
+ summary: launchResult.unavailable.summary,
59
123
  lease,
60
124
  activeLeaseCount,
61
- reusedInstance: true,
62
125
  cleanedStaleInstance,
63
- instance: reusedInstance,
64
- inventoryObservedAt: input.status.inventoryObservedAt,
65
- projectConfigPresent: input.projectConfig !== null,
66
- rawPayload: validation.rawPayload ?? existing.rawPayload,
67
126
  });
68
127
  }
69
- cleanedStaleInstance = true;
70
- await cleanupPersistedInstance(worktree, existing.instance, validation.reason);
71
- }
72
- const launchResult = await launchManagedInstance(worktree, path.resolve(input.targetPath, input.status.primaryProjectPath ?? "."));
73
- if ("unavailable" in launchResult) {
128
+ const launchedInstance = {
129
+ instanceId: launchResult.instanceId,
130
+ launcher: launchResult.launcher,
131
+ port: launchResult.port,
132
+ pid: launchResult.pid,
133
+ projectPath,
134
+ command: launchResult.command,
135
+ trust: launchResult.trust,
136
+ launchedAt: new Date().toISOString(),
137
+ lastValidatedAt: new Date().toISOString(),
138
+ identity: launchResult.identity,
139
+ };
140
+ await persistInstance(worktree, {
141
+ schemaVersion: LSP_INSTANCE_SCHEMA_VERSION,
142
+ instance: launchedInstance,
143
+ validationMode: launchResult.validationMode,
144
+ diagnostics: launchResult.diagnostics,
145
+ rawPayload: launchResult.rawPayload,
146
+ });
147
+ return createStatusResult({
148
+ targetPath: input.targetPath,
149
+ readiness: input.status.readiness,
150
+ availability: "available",
151
+ status: launchResult.trust === "trusted" ? "ready" : "degraded",
152
+ validationMode: launchResult.validationMode,
153
+ summary: launchResult.trust === "trusted"
154
+ ? "Started a managed authoring.lsp instance for this worktree."
155
+ : "Started a managed authoring.lsp instance, but trust is degraded and validation should be treated cautiously.",
156
+ reasons: dedupe([
157
+ ...stateLock.recoveredReasons,
158
+ ...(cleanedStaleInstance ? ["stale_lsp_instance_cleaned"] : []),
159
+ ...cleanupReasons,
160
+ ...(launchResult.trust === "trusted" ? [] : ["lsp_instance_degraded"]),
161
+ ]),
162
+ worktree,
163
+ lease,
164
+ activeLeaseCount,
165
+ reusedInstance: false,
166
+ cleanedStaleInstance,
167
+ instance: launchedInstance,
168
+ inventoryObservedAt: input.status.inventoryObservedAt,
169
+ projectConfigPresent: input.projectConfig !== null,
170
+ rawPayload: launchResult.rawPayload,
171
+ });
172
+ }));
173
+ }
174
+ catch (error) {
175
+ if (error instanceof GdhManagedLspStateLockError) {
74
176
  return createUnavailableStatus({
75
177
  targetPath: input.targetPath,
76
178
  readiness: input.status.readiness,
@@ -78,59 +180,297 @@ export async function getManagedLspStatus(input) {
78
180
  inventoryObservedAt: input.status.inventoryObservedAt,
79
181
  projectConfigPresent: input.projectConfig !== null,
80
182
  availability: "unavailable",
81
- reasons: [
82
- ...(cleanedStaleInstance ? ["stale_lsp_instance_cleaned"] : []),
83
- launchResult.unavailable.reason,
183
+ reasons: error.reasons,
184
+ summary: "authoring.lsp state is temporarily unavailable because another process is mutating managed LSP state for this worktree.",
185
+ });
186
+ }
187
+ throw error;
188
+ }
189
+ }
190
+ export async function checkManagedLsp(input) {
191
+ const worktree = resolveWorktreeIdentity(input.targetPath, input.status, input.projectConfig);
192
+ return await withManagedLspLifecycleLock(worktree, "check", "gdh lsp check", async (stateLock) => {
193
+ const persisted = await readPersistedInstance(worktree);
194
+ const expectedIdentity = await resolveCurrentManagedLspIdentity(worktree, path.resolve(input.targetPath, input.status.primaryProjectPath ?? "."));
195
+ if (persisted === null) {
196
+ return createLifecycleCommandResult({
197
+ targetPath: input.targetPath,
198
+ command: "check",
199
+ status: "not_found",
200
+ summary: "No persisted managed authoring.lsp instance exists for this worktree.",
201
+ reasons: dedupe([...stateLock.recoveredReasons, "lsp_instance_not_found"]),
202
+ worktree,
203
+ instance: null,
204
+ transportStatus: "unknown",
205
+ transportReasons: [],
206
+ transportRawPayload: null,
207
+ cleanupStatus: "not_needed",
208
+ cleanupReason: null,
209
+ killedPid: false,
210
+ recommendations: [
211
+ "Run `gdh lsp status` or `gdh lsp restart` to launch a managed instance.",
84
212
  ],
85
- summary: launchResult.unavailable.summary,
86
- lease,
87
- activeLeaseCount,
88
- cleanedStaleInstance,
213
+ statusResult: null,
89
214
  });
90
215
  }
91
- const launchedInstance = {
92
- instanceId: launchResult.instanceId,
93
- launcher: launchResult.launcher,
94
- port: launchResult.port,
95
- pid: launchResult.pid,
96
- projectPath: path.resolve(input.targetPath, input.status.primaryProjectPath ?? "."),
97
- command: launchResult.command,
98
- trust: launchResult.trust,
99
- launchedAt: new Date().toISOString(),
100
- lastValidatedAt: new Date().toISOString(),
101
- };
102
- await persistInstance(worktree, {
103
- schemaVersion: LSP_INSTANCE_SCHEMA_VERSION,
104
- instance: launchedInstance,
105
- validationMode: launchResult.validationMode,
106
- diagnostics: launchResult.diagnostics,
107
- rawPayload: launchResult.rawPayload,
216
+ const validation = await validatePersistedInstance(persisted.instance, expectedIdentity);
217
+ if (validation.reusable) {
218
+ const cleanupPolicy = classifyManagedLspCleanupPolicy(persisted.instance);
219
+ if (cleanupPolicy !== null) {
220
+ return createLifecycleCommandResult({
221
+ targetPath: input.targetPath,
222
+ command: "check",
223
+ status: "degraded",
224
+ summary: "The persisted managed authoring.lsp instance is protocol-healthy but should be cleaned up by lifecycle policy.",
225
+ reasons: dedupe([...stateLock.recoveredReasons, ...cleanupPolicy.reasons]),
226
+ worktree,
227
+ instance: persisted.instance,
228
+ transportStatus: "healthy",
229
+ transportReasons: [],
230
+ transportRawPayload: {
231
+ ...(validation.rawPayload ?? {}),
232
+ cleanupPolicy,
233
+ },
234
+ cleanupStatus: "recommended",
235
+ cleanupReason: cleanupPolicy.reason,
236
+ killedPid: false,
237
+ recommendations: ["Run `gdh lsp prune` to remove stale managed LSP state."],
238
+ statusResult: null,
239
+ });
240
+ }
241
+ const lifecycleStatus = validation.trust === "trusted" ? "ready" : "degraded";
242
+ return createLifecycleCommandResult({
243
+ targetPath: input.targetPath,
244
+ command: "check",
245
+ status: lifecycleStatus,
246
+ summary: validation.trust === "trusted"
247
+ ? "The persisted managed authoring.lsp instance is protocol-healthy."
248
+ : "The persisted managed authoring.lsp instance is reachable, but trust is degraded.",
249
+ reasons: dedupe([
250
+ ...stateLock.recoveredReasons,
251
+ ...(validation.trust === "trusted" ? [] : ["lsp_instance_degraded"]),
252
+ ]),
253
+ worktree,
254
+ instance: persisted.instance,
255
+ transportStatus: "healthy",
256
+ transportReasons: [],
257
+ transportRawPayload: validation.rawPayload,
258
+ cleanupStatus: "none",
259
+ cleanupReason: null,
260
+ killedPid: false,
261
+ recommendations: [],
262
+ statusResult: null,
263
+ });
264
+ }
265
+ return createLifecycleCommandResult({
266
+ targetPath: input.targetPath,
267
+ command: "check",
268
+ status: "unavailable",
269
+ summary: "The persisted managed authoring.lsp instance is not protocol-healthy and should be restarted or pruned.",
270
+ reasons: dedupe([...stateLock.recoveredReasons, ...validation.reasons]),
271
+ worktree,
272
+ instance: persisted.instance,
273
+ transportStatus: "unhealthy",
274
+ transportReasons: validation.reasons,
275
+ transportRawPayload: validation.rawPayload,
276
+ cleanupStatus: "recommended",
277
+ cleanupReason: validation.reason,
278
+ killedPid: false,
279
+ recommendations: ["Run `gdh lsp restart` to replace the managed instance."],
280
+ statusResult: null,
281
+ });
282
+ });
283
+ }
284
+ export async function stopManagedLsp(input) {
285
+ const worktree = resolveWorktreeIdentity(input.targetPath, input.status, input.projectConfig);
286
+ return await withManagedLspLifecycleLock(worktree, "stop", "gdh lsp stop", async (stateLock) => {
287
+ const persisted = await readPersistedInstance(worktree);
288
+ const expectedIdentity = await resolveCurrentManagedLspIdentity(worktree, path.resolve(input.targetPath, input.status.primaryProjectPath ?? "."));
289
+ if (persisted === null) {
290
+ return createLifecycleCommandResult({
291
+ targetPath: input.targetPath,
292
+ command: "stop",
293
+ status: "not_found",
294
+ summary: "No persisted managed authoring.lsp instance exists for this worktree.",
295
+ reasons: dedupe([...stateLock.recoveredReasons, "lsp_instance_not_found"]),
296
+ worktree,
297
+ instance: null,
298
+ transportStatus: "unknown",
299
+ transportReasons: [],
300
+ transportRawPayload: null,
301
+ cleanupStatus: "not_needed",
302
+ cleanupReason: null,
303
+ killedPid: false,
304
+ recommendations: [],
305
+ statusResult: null,
306
+ });
307
+ }
308
+ const cleanup = await cleanupPersistedInstance(worktree, persisted.instance, "lsp_stop_requested", {
309
+ killProcess: true,
310
+ expectedIdentity,
108
311
  });
109
- return createStatusResult({
312
+ return createLifecycleCommandResult({
110
313
  targetPath: input.targetPath,
111
- readiness: input.status.readiness,
112
- availability: "available",
113
- status: launchResult.trust === "trusted" ? "ready" : "degraded",
114
- validationMode: launchResult.validationMode,
115
- summary: launchResult.trust === "trusted"
116
- ? "Started a managed authoring.lsp instance for this worktree."
117
- : "Started a managed authoring.lsp instance, but trust is degraded and validation should be treated cautiously.",
118
- reasons: dedupe([
119
- ...(cleanedStaleInstance ? ["stale_lsp_instance_cleaned"] : []),
120
- ...(launchResult.trust === "trusted" ? [] : ["lsp_instance_degraded"]),
121
- ]),
314
+ command: "stop",
315
+ status: "stopped",
316
+ summary: "Stopped the persisted GDH-managed authoring.lsp instance for this worktree.",
317
+ reasons: stateLock.recoveredReasons,
122
318
  worktree,
123
- lease,
124
- activeLeaseCount,
125
- reusedInstance: false,
126
- cleanedStaleInstance,
127
- instance: launchedInstance,
128
- inventoryObservedAt: input.status.inventoryObservedAt,
129
- projectConfigPresent: input.projectConfig !== null,
130
- rawPayload: launchResult.rawPayload,
319
+ instance: persisted.instance,
320
+ transportStatus: "unknown",
321
+ transportReasons: [],
322
+ transportRawPayload: persisted.rawPayload,
323
+ cleanupStatus: "stopped",
324
+ cleanupReason: "lsp_stop_requested",
325
+ killedPid: cleanup.killedPid,
326
+ recommendations: [],
327
+ statusResult: null,
131
328
  });
132
329
  });
133
330
  }
331
+ export async function restartManagedLsp(input) {
332
+ const stopped = await stopManagedLsp(input);
333
+ const statusResult = await getManagedLspStatus({
334
+ targetPath: input.targetPath,
335
+ status: input.status,
336
+ projectConfig: input.projectConfig,
337
+ owner: input.owner ?? "gdh lsp restart",
338
+ });
339
+ return createLifecycleCommandResult({
340
+ targetPath: input.targetPath,
341
+ command: "restart",
342
+ status: statusResult.status === "ready" ? "ready" : statusResult.status,
343
+ summary: statusResult.status === "ready"
344
+ ? "Restarted the managed authoring.lsp instance for this worktree."
345
+ : `Restart attempted, but managed authoring.lsp is ${statusResult.status}.`,
346
+ reasons: dedupe([...stopped.reasons, ...statusResult.reasons]),
347
+ worktree: statusResult.worktree,
348
+ instance: statusResult.instance,
349
+ transportStatus: statusResult.status === "ready" ? "healthy" : "unhealthy",
350
+ transportReasons: statusResult.reasons,
351
+ transportRawPayload: statusResult.provenance.rawPayload,
352
+ cleanupStatus: stopped.cleanup.status === "stopped" ? "stopped" : "not_needed",
353
+ cleanupReason: stopped.cleanup.reason,
354
+ killedPid: stopped.cleanup.killedPid,
355
+ recommendations: statusResult.status === "ready"
356
+ ? []
357
+ : ["Inspect `reasons` and run `gdh lsp doctor` for cleanup guidance."],
358
+ statusResult,
359
+ });
360
+ }
361
+ export async function pruneManagedLsp(input) {
362
+ const worktree = resolveWorktreeIdentity(input.targetPath, input.status, input.projectConfig);
363
+ return await withManagedLspLifecycleLock(worktree, "prune", "gdh lsp prune", async (stateLock) => {
364
+ const persisted = await readPersistedInstance(worktree);
365
+ const expectedIdentity = await resolveCurrentManagedLspIdentity(worktree, path.resolve(input.targetPath, input.status.primaryProjectPath ?? "."));
366
+ if (persisted === null) {
367
+ return createLifecycleCommandResult({
368
+ targetPath: input.targetPath,
369
+ command: "prune",
370
+ status: "not_found",
371
+ summary: "No stale managed authoring.lsp state exists for this worktree.",
372
+ reasons: dedupe([...stateLock.recoveredReasons, "lsp_instance_not_found"]),
373
+ worktree,
374
+ instance: null,
375
+ transportStatus: "unknown",
376
+ transportReasons: [],
377
+ transportRawPayload: null,
378
+ cleanupStatus: "not_needed",
379
+ cleanupReason: null,
380
+ killedPid: false,
381
+ recommendations: [],
382
+ statusResult: null,
383
+ });
384
+ }
385
+ const validation = await validatePersistedInstance(persisted.instance, expectedIdentity);
386
+ if (validation.reusable) {
387
+ const cleanupPolicy = classifyManagedLspCleanupPolicy(persisted.instance);
388
+ if (cleanupPolicy !== null) {
389
+ const cleanup = await cleanupPersistedInstance(worktree, persisted.instance, cleanupPolicy.reason, {
390
+ killProcess: true,
391
+ expectedIdentity,
392
+ });
393
+ return createLifecycleCommandResult({
394
+ targetPath: input.targetPath,
395
+ command: "prune",
396
+ status: "pruned",
397
+ summary: "Pruned idle or stale managed authoring.lsp state for this worktree.",
398
+ reasons: dedupe([...stateLock.recoveredReasons, ...cleanupPolicy.reasons]),
399
+ worktree,
400
+ instance: persisted.instance,
401
+ transportStatus: "healthy",
402
+ transportReasons: [],
403
+ transportRawPayload: {
404
+ ...(validation.rawPayload ?? {}),
405
+ cleanupPolicy,
406
+ },
407
+ cleanupStatus: "pruned",
408
+ cleanupReason: cleanupPolicy.reason,
409
+ killedPid: cleanup.killedPid,
410
+ recommendations: ["Run `gdh lsp status` when a managed instance is needed again."],
411
+ statusResult: null,
412
+ });
413
+ }
414
+ return createLifecycleCommandResult({
415
+ targetPath: input.targetPath,
416
+ command: "prune",
417
+ status: validation.trust === "trusted" ? "ready" : "degraded",
418
+ summary: "The persisted managed authoring.lsp instance is still reusable; prune did not remove it.",
419
+ reasons: dedupe([
420
+ ...stateLock.recoveredReasons,
421
+ ...(validation.trust === "trusted" ? [] : ["lsp_instance_degraded"]),
422
+ ]),
423
+ worktree,
424
+ instance: persisted.instance,
425
+ transportStatus: "healthy",
426
+ transportReasons: [],
427
+ transportRawPayload: validation.rawPayload,
428
+ cleanupStatus: "none",
429
+ cleanupReason: null,
430
+ killedPid: false,
431
+ recommendations: [],
432
+ statusResult: null,
433
+ });
434
+ }
435
+ const cleanup = await cleanupPersistedInstance(worktree, persisted.instance, validation.reason, {
436
+ killProcess: false,
437
+ expectedIdentity,
438
+ });
439
+ return createLifecycleCommandResult({
440
+ targetPath: input.targetPath,
441
+ command: "prune",
442
+ status: "pruned",
443
+ summary: "Pruned stale managed authoring.lsp state without killing a live process.",
444
+ reasons: dedupe([...stateLock.recoveredReasons, ...validation.reasons]),
445
+ worktree,
446
+ instance: persisted.instance,
447
+ transportStatus: "unhealthy",
448
+ transportReasons: validation.reasons,
449
+ transportRawPayload: validation.rawPayload,
450
+ cleanupStatus: "pruned",
451
+ cleanupReason: validation.reason,
452
+ killedPid: cleanup.killedPid,
453
+ recommendations: ["Run `gdh lsp status` when a managed instance is needed again."],
454
+ statusResult: null,
455
+ });
456
+ });
457
+ }
458
+ export async function doctorManagedLsp(input) {
459
+ const checked = await checkManagedLsp(input);
460
+ return {
461
+ ...checked,
462
+ command: "doctor",
463
+ summary: checked.status === "ready"
464
+ ? "Managed authoring.lsp lifecycle and transport are healthy. Use `gdh authoring check` to collect validator evidence."
465
+ : checked.summary,
466
+ recommendations: checked.status === "ready"
467
+ ? [
468
+ "Use `gdh authoring check` to collect GDScript LSP diagnostics and scene/resource validator evidence.",
469
+ "`gdh lsp status` and `gdh lsp doctor` report lifecycle health only; they are not diagnostic evidence.",
470
+ ]
471
+ : checked.recommendations,
472
+ };
473
+ }
134
474
  export async function runManagedAuthoringCheck(input) {
135
475
  const pendingImportState = {
136
476
  status: "unknown",
@@ -139,9 +479,6 @@ export async function runManagedAuthoringCheck(input) {
139
479
  seedPath: null,
140
480
  worktreePath: null,
141
481
  };
142
- const persisted = input.lspStatus.instance
143
- ? await readPersistedInstance(input.lspStatus.worktree)
144
- : null;
145
482
  if (input.lspStatus.status === "unavailable") {
146
483
  return {
147
484
  targetPath: input.targetPath,
@@ -170,10 +507,16 @@ export async function runManagedAuthoringCheck(input) {
170
507
  },
171
508
  };
172
509
  }
510
+ const persisted = input.lspStatus.instance
511
+ ? await readOrCollectPersistedDiagnostics(input)
512
+ : null;
173
513
  const validationMode = persisted?.validationMode ?? "unimplemented";
174
514
  const diagnostics = persisted?.diagnostics ?? [];
175
515
  const rawPayload = persisted?.rawPayload ?? input.lspStatus.provenance.rawPayload;
176
516
  const trust = input.lspStatus.instance?.trust ?? null;
517
+ const validationFailureReason = rawPayload !== null && typeof rawPayload["failureReason"] === "string"
518
+ ? rawPayload["failureReason"]
519
+ : null;
177
520
  if (input.lspStatus.status === "degraded" || validationMode === "degraded") {
178
521
  return {
179
522
  targetPath: input.targetPath,
@@ -187,6 +530,7 @@ export async function runManagedAuthoringCheck(input) {
187
530
  reasons: dedupe([
188
531
  ...input.lspStatus.reasons,
189
532
  validationMode === "degraded" ? "lsp_validation_degraded" : "",
533
+ validationFailureReason ?? "",
190
534
  ].filter(Boolean)),
191
535
  provenance: {
192
536
  source: "authoring.lsp",
@@ -266,11 +610,11 @@ export async function runManagedAuthoringCheck(input) {
266
610
  capability: "authoring.lsp",
267
611
  readiness: input.status.readiness,
268
612
  status: "degraded",
269
- summary: "The managed authoring.lsp instance is ready, but GDH has not implemented Godot diagnostic collection yet.",
613
+ summary: "The managed authoring.lsp instance is ready, but did not report a supported diagnostic mode.",
270
614
  diagnostics: [],
271
615
  importState: pendingImportState,
272
616
  caveats: [],
273
- reasons: ["lsp_transport_not_implemented"],
617
+ reasons: ["lsp_validation_mode_unimplemented"],
274
618
  provenance: {
275
619
  source: "authoring.lsp",
276
620
  inventoryObservedAt: input.status.inventoryObservedAt,
@@ -288,6 +632,243 @@ export async function runManagedAuthoringCheck(input) {
288
632
  },
289
633
  };
290
634
  }
635
+ async function readOrCollectPersistedDiagnostics(input) {
636
+ if (input.lspStatus.instance === null) {
637
+ return null;
638
+ }
639
+ try {
640
+ return await withManagedLspStateLock(input.lspStatus.worktree, "gdh authoring check diagnostics", async () => {
641
+ const persisted = await readPersistedInstance(input.lspStatus.worktree);
642
+ if (persisted === null ||
643
+ input.lspStatus.status !== "ready" ||
644
+ input.lspStatus.instance === null ||
645
+ persisted.instance.launcher !== "godot_editor") {
646
+ return persisted;
647
+ }
648
+ const collected = await collectManagedGdscriptDiagnostics({
649
+ targetPath: input.targetPath,
650
+ status: input.status,
651
+ worktree: input.lspStatus.worktree,
652
+ instance: input.lspStatus.instance,
653
+ persisted,
654
+ });
655
+ await persistInstance(input.lspStatus.worktree, collected);
656
+ return collected;
657
+ });
658
+ }
659
+ catch (error) {
660
+ if (error instanceof GdhManagedLspStateLockError) {
661
+ return {
662
+ schemaVersion: LSP_INSTANCE_SCHEMA_VERSION,
663
+ instance: input.lspStatus.instance,
664
+ validationMode: "degraded",
665
+ diagnostics: [],
666
+ rawPayload: {
667
+ collection: "gdscript_diagnostics",
668
+ diagnosticMethod: "textDocument/publishDiagnostics",
669
+ failureReason: error.reason,
670
+ reasons: error.reasons,
671
+ },
672
+ };
673
+ }
674
+ throw error;
675
+ }
676
+ }
677
+ async function collectManagedGdscriptDiagnostics(input) {
678
+ const projectPath = path.resolve(input.targetPath, input.status.primaryProjectPath ?? ".");
679
+ const files = await listGdscriptFiles(projectPath);
680
+ const checkedFiles = files.map((filePath) => toProjectRelativePath(projectPath, filePath));
681
+ if (files.length === 0) {
682
+ return {
683
+ ...input.persisted,
684
+ validationMode: "passed",
685
+ diagnostics: [],
686
+ rawPayload: {
687
+ collection: "gdscript_diagnostics",
688
+ diagnosticMethod: "textDocument/publishDiagnostics",
689
+ projectPath,
690
+ checkedFiles,
691
+ diagnosticCollections: [],
692
+ },
693
+ };
694
+ }
695
+ try {
696
+ const client = await connectGodotLspClient({
697
+ port: input.instance.port,
698
+ connectTimeoutMs: MANAGED_LSP_DIAGNOSTIC_CONNECT_TIMEOUT_MS,
699
+ requestTimeoutMs: MANAGED_LSP_DIAGNOSTIC_REQUEST_TIMEOUT_MS,
700
+ });
701
+ const diagnosticCollections = [];
702
+ try {
703
+ await client.initialize({
704
+ rootUri: pathToFileURL(projectPath).href,
705
+ });
706
+ await client.initialized();
707
+ for (const filePath of files) {
708
+ const uri = pathToFileURL(filePath).href;
709
+ const diagnosticsPromise = client.waitForDiagnostics({
710
+ uri,
711
+ timeoutMs: resolveManagedLspDiagnosticWaitTimeoutMs(),
712
+ });
713
+ await client.didOpen({
714
+ uri,
715
+ languageId: "gdscript",
716
+ version: 1,
717
+ text: await fs.readFile(filePath, "utf8"),
718
+ });
719
+ diagnosticCollections.push(await diagnosticsPromise);
720
+ }
721
+ }
722
+ finally {
723
+ await client.close();
724
+ }
725
+ const diagnostics = diagnosticCollections.flatMap((collection) => collection.diagnostics.map((diagnostic) => normalizeGdscriptDiagnostic(projectPath, collection.uri, diagnostic)));
726
+ return {
727
+ ...input.persisted,
728
+ validationMode: diagnostics.length > 0 ? "diagnostics" : "passed",
729
+ diagnostics,
730
+ rawPayload: {
731
+ collection: "gdscript_diagnostics",
732
+ diagnosticMethod: "textDocument/publishDiagnostics",
733
+ projectPath,
734
+ checkedFiles,
735
+ diagnosticCollections: diagnosticCollections.map((collection) => ({
736
+ uri: collection.uri,
737
+ path: fileUriToProjectRelativePath(projectPath, collection.uri),
738
+ diagnosticsCount: collection.diagnostics.length,
739
+ diagnostics: collection.diagnostics,
740
+ })),
741
+ },
742
+ };
743
+ }
744
+ catch (error) {
745
+ if (error instanceof GdhLspClientError) {
746
+ return {
747
+ ...input.persisted,
748
+ validationMode: "degraded",
749
+ diagnostics: [],
750
+ rawPayload: {
751
+ collection: "gdscript_diagnostics",
752
+ diagnosticMethod: "textDocument/publishDiagnostics",
753
+ projectPath,
754
+ checkedFiles,
755
+ diagnosticCollections: [],
756
+ failureReason: error.reason,
757
+ failureDetails: error.details,
758
+ },
759
+ };
760
+ }
761
+ throw error;
762
+ }
763
+ }
764
+ async function listGdscriptFiles(projectPath) {
765
+ const ignoredDirectoryNames = new Set([
766
+ ".git",
767
+ ".godot",
768
+ ".gdh-state",
769
+ "node_modules",
770
+ "dist",
771
+ "coverage",
772
+ ]);
773
+ const files = [];
774
+ const visit = async (directoryPath) => {
775
+ let entries;
776
+ try {
777
+ entries = await fs.readdir(directoryPath, { withFileTypes: true });
778
+ }
779
+ catch {
780
+ return;
781
+ }
782
+ for (const entry of entries) {
783
+ if (entry.isDirectory()) {
784
+ if (!ignoredDirectoryNames.has(entry.name)) {
785
+ await visit(path.join(directoryPath, entry.name));
786
+ }
787
+ continue;
788
+ }
789
+ if (entry.isFile() && entry.name.endsWith(".gd")) {
790
+ files.push(path.join(directoryPath, entry.name));
791
+ }
792
+ }
793
+ };
794
+ await visit(projectPath);
795
+ return files.sort((a, b) => a.localeCompare(b));
796
+ }
797
+ function normalizeGdscriptDiagnostic(projectPath, uri, diagnostic) {
798
+ const range = normalizeLspRange(diagnostic.range);
799
+ return {
800
+ message: diagnostic.message,
801
+ path: fileUriToProjectRelativePath(projectPath, uri),
802
+ line: range.line,
803
+ column: range.column,
804
+ endLine: range.endLine,
805
+ endColumn: range.endColumn,
806
+ severity: normalizeLspSeverity(diagnostic.severity),
807
+ source: "godot_lsp",
808
+ raw: diagnostic.raw,
809
+ };
810
+ }
811
+ function normalizeLspSeverity(value) {
812
+ if (value === 1) {
813
+ return "error";
814
+ }
815
+ if (value === 2) {
816
+ return "warning";
817
+ }
818
+ return "info";
819
+ }
820
+ function normalizeLspRange(range) {
821
+ if (typeof range !== "object" || range === null) {
822
+ return { line: null, column: null, endLine: null, endColumn: null };
823
+ }
824
+ const candidate = range;
825
+ const startLine = numberOrNull(candidate.start?.line);
826
+ const startCharacter = numberOrNull(candidate.start?.character);
827
+ const endLine = numberOrNull(candidate.end?.line);
828
+ const endCharacter = numberOrNull(candidate.end?.character);
829
+ return {
830
+ line: startLine === null ? null : startLine + 1,
831
+ column: startCharacter === null ? null : startCharacter + 1,
832
+ endLine: endLine === null ? null : endLine + 1,
833
+ endColumn: endCharacter === null ? null : endCharacter + 1,
834
+ };
835
+ }
836
+ function numberOrNull(value) {
837
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
838
+ }
839
+ function fileUriToProjectRelativePath(projectPath, uri) {
840
+ if (!uri.startsWith("file://")) {
841
+ return null;
842
+ }
843
+ try {
844
+ return toProjectRelativePath(canonicalExistingPath(projectPath), canonicalExistingPath(fileURLToPath(uri)));
845
+ }
846
+ catch {
847
+ return null;
848
+ }
849
+ }
850
+ function canonicalExistingPath(filePath) {
851
+ try {
852
+ return realpathSync.native(filePath);
853
+ }
854
+ catch {
855
+ return path.resolve(filePath);
856
+ }
857
+ }
858
+ function resolveManagedLspDiagnosticWaitTimeoutMs() {
859
+ const configured = process.env["GDH_TEST_LSP_DIAGNOSTIC_WAIT_TIMEOUT_MS"];
860
+ if (configured === undefined) {
861
+ return MANAGED_LSP_DIAGNOSTIC_WAIT_TIMEOUT_MS;
862
+ }
863
+ const parsed = Number(configured);
864
+ if (!Number.isFinite(parsed) || parsed <= 0) {
865
+ return MANAGED_LSP_DIAGNOSTIC_WAIT_TIMEOUT_MS;
866
+ }
867
+ return parsed;
868
+ }
869
+ function toProjectRelativePath(projectPath, filePath) {
870
+ return path.relative(projectPath, filePath).split(path.sep).join("/");
871
+ }
291
872
  export async function resetManagedLspTestState() {
292
873
  testFakeRegistry.clear();
293
874
  }
@@ -302,6 +883,209 @@ function resolveWorktreeIdentity(targetPath, status, projectConfig) {
302
883
  stateRootPath,
303
884
  };
304
885
  }
886
+ async function resolveCurrentManagedLspIdentity(worktree, projectPath) {
887
+ const testMode = process.env["GDH_TEST_LSP_MODE"];
888
+ if (testMode) {
889
+ return await createManagedLspInstanceIdentity({
890
+ worktree,
891
+ projectPath,
892
+ launcher: "test_fake",
893
+ editorBinPath: `test_fake:${normalizeTestMode(testMode)}`,
894
+ editorVersion: normalizeTestMode(testMode),
895
+ editorVersionReason: null,
896
+ });
897
+ }
898
+ const godotBin = await resolveConfiguredGodotEditorBin({
899
+ targetPath: worktree.rootPath,
900
+ environment: process.env,
901
+ });
902
+ const version = godotBin === null ? null : await probeGodotEditorVersion(godotBin);
903
+ return await createManagedLspInstanceIdentity({
904
+ worktree,
905
+ projectPath,
906
+ launcher: "godot_editor",
907
+ editorBinPath: godotBin,
908
+ editorVersion: version?.version ?? null,
909
+ editorVersionReason: godotBin === null
910
+ ? "godot_editor_not_configured"
911
+ : (version?.reason ?? "godot_editor_version_unavailable"),
912
+ });
913
+ }
914
+ async function createManagedLspInstanceIdentity(input) {
915
+ const worktreeRootPath = await canonicalizePath(input.worktree.rootPath);
916
+ const projectRootPath = await canonicalizePath(input.projectPath);
917
+ const identitySeed = JSON.stringify({
918
+ worktreeRootPath,
919
+ projectRootPath,
920
+ worktreeKey: input.worktree.worktreeKey,
921
+ projectKey: input.worktree.projectKey,
922
+ launcher: input.launcher,
923
+ editorBinPath: input.editorBinPath,
924
+ editorVersion: input.editorVersion,
925
+ lspSurfaceVersion: GDH_MANAGED_LSP_SURFACE_VERSION,
926
+ });
927
+ return {
928
+ schemaVersion: GDH_MANAGED_LSP_SURFACE_VERSION,
929
+ identityKey: hashKey(identitySeed),
930
+ worktreeRootPath,
931
+ projectRootPath,
932
+ worktreeKey: input.worktree.worktreeKey,
933
+ projectKey: input.worktree.projectKey,
934
+ launcher: input.launcher,
935
+ editorBinPath: input.editorBinPath,
936
+ editorVersion: input.editorVersion,
937
+ editorVersionReason: input.editorVersionReason,
938
+ lspSurfaceVersion: GDH_MANAGED_LSP_SURFACE_VERSION,
939
+ };
940
+ }
941
+ async function canonicalizePath(value) {
942
+ const resolved = path.resolve(value);
943
+ return await fs.realpath(resolved).catch(() => resolved);
944
+ }
945
+ async function probeGodotEditorVersion(godotBin) {
946
+ return await new Promise((resolve) => {
947
+ const child = execFile(godotBin, ["--version"], { timeout: 1_000 }, (error, stdout) => {
948
+ if (error) {
949
+ resolve({
950
+ version: null,
951
+ reason: "godot_editor_version_probe_failed",
952
+ });
953
+ return;
954
+ }
955
+ const version = stdout.trim().split(/\s+/)[0] ?? "";
956
+ resolve({
957
+ version: version.length > 0 ? version : null,
958
+ reason: version.length > 0 ? null : "godot_editor_version_empty",
959
+ });
960
+ });
961
+ child.once("error", () => {
962
+ resolve({
963
+ version: null,
964
+ reason: "godot_editor_version_probe_failed",
965
+ });
966
+ });
967
+ });
968
+ }
969
+ async function withManagedLspStateLock(worktree, owner, callback) {
970
+ const lock = await acquireManagedLspStateLock(worktree, owner);
971
+ try {
972
+ return await callback(lock);
973
+ }
974
+ finally {
975
+ await releaseManagedLspStateLock(worktree, lock);
976
+ }
977
+ }
978
+ async function withManagedLspLifecycleLock(worktree, command, owner, callback) {
979
+ try {
980
+ return await withManagedLspStateLock(worktree, owner, callback);
981
+ }
982
+ catch (error) {
983
+ if (error instanceof GdhManagedLspStateLockError) {
984
+ return createLifecycleCommandResult({
985
+ targetPath: worktree.rootPath,
986
+ command,
987
+ status: "unavailable",
988
+ summary: "Managed authoring.lsp state is temporarily unavailable because another process is mutating it for this worktree.",
989
+ reasons: error.reasons,
990
+ worktree,
991
+ instance: null,
992
+ transportStatus: "unknown",
993
+ transportReasons: error.reasons,
994
+ transportRawPayload: null,
995
+ cleanupStatus: "none",
996
+ cleanupReason: error.reason,
997
+ killedPid: false,
998
+ recommendations: ["Retry the LSP lifecycle command after the current mutation finishes."],
999
+ statusResult: null,
1000
+ });
1001
+ }
1002
+ throw error;
1003
+ }
1004
+ }
1005
+ async function acquireManagedLspStateLock(worktree, owner) {
1006
+ await fs.mkdir(worktree.stateRootPath, { recursive: true });
1007
+ const lockPath = path.join(worktree.stateRootPath, "lsp.lock");
1008
+ const metadataPath = path.join(lockPath, "metadata.json");
1009
+ const startedAt = Date.now();
1010
+ const recoveredReasons = [];
1011
+ while (Date.now() - startedAt < LSP_STATE_LOCK_ACQUIRE_TIMEOUT_MS) {
1012
+ const acquiredAt = new Date().toISOString();
1013
+ const record = {
1014
+ schemaVersion: LSP_STATE_LOCK_SCHEMA_VERSION,
1015
+ lockId: randomUUID(),
1016
+ owner,
1017
+ pid: process.pid,
1018
+ acquiredAt,
1019
+ staleAfterMs: LSP_STATE_LOCK_STALE_AFTER_MS,
1020
+ acquireTimeoutMs: LSP_STATE_LOCK_ACQUIRE_TIMEOUT_MS,
1021
+ };
1022
+ try {
1023
+ await fs.mkdir(lockPath);
1024
+ await fs.writeFile(metadataPath, JSON.stringify(record, null, 2), "utf8");
1025
+ return {
1026
+ lockId: record.lockId,
1027
+ owner,
1028
+ acquiredAt,
1029
+ recoveredReasons: dedupe(recoveredReasons),
1030
+ };
1031
+ }
1032
+ catch (error) {
1033
+ const code = error.code;
1034
+ if (code !== "EEXIST") {
1035
+ throw error;
1036
+ }
1037
+ const staleReason = await detectManagedLspStateLockStaleReason(metadataPath);
1038
+ if (staleReason !== null) {
1039
+ recoveredReasons.push(staleReason);
1040
+ await fs.rm(lockPath, { recursive: true, force: true });
1041
+ continue;
1042
+ }
1043
+ await sleep(LSP_STATE_LOCK_RETRY_DELAY_MS);
1044
+ }
1045
+ }
1046
+ throw new GdhManagedLspStateLockError("lsp_state_lock_timeout", ["lsp_state_lock_timeout"]);
1047
+ }
1048
+ async function detectManagedLspStateLockStaleReason(metadataPath) {
1049
+ const content = await fs.readFile(metadataPath, "utf8").catch(() => null);
1050
+ if (content === null) {
1051
+ const lockStats = await fs.stat(path.dirname(metadataPath)).catch(() => null);
1052
+ if (lockStats !== null && Date.now() - lockStats.mtimeMs >= LSP_STATE_LOCK_STALE_AFTER_MS) {
1053
+ return "lsp_state_lock_metadata_invalid";
1054
+ }
1055
+ return null;
1056
+ }
1057
+ let metadata;
1058
+ try {
1059
+ metadata = JSON.parse(content);
1060
+ }
1061
+ catch {
1062
+ return "lsp_state_lock_metadata_invalid";
1063
+ }
1064
+ if (metadata.schemaVersion !== LSP_STATE_LOCK_SCHEMA_VERSION ||
1065
+ typeof metadata.pid !== "number" ||
1066
+ typeof metadata.acquiredAt !== "string") {
1067
+ return "lsp_state_lock_metadata_invalid";
1068
+ }
1069
+ if (!isPidAlive(metadata.pid)) {
1070
+ return "lsp_state_lock_recovered_dead_pid";
1071
+ }
1072
+ const staleAfterMs = typeof metadata.staleAfterMs === "number"
1073
+ ? metadata.staleAfterMs
1074
+ : LSP_STATE_LOCK_STALE_AFTER_MS;
1075
+ if (Date.now() - Date.parse(metadata.acquiredAt) >= staleAfterMs) {
1076
+ return "lsp_state_lock_recovered_expired";
1077
+ }
1078
+ return null;
1079
+ }
1080
+ async function releaseManagedLspStateLock(worktree, lock) {
1081
+ const lockPath = path.join(worktree.stateRootPath, "lsp.lock");
1082
+ const metadataPath = path.join(lockPath, "metadata.json");
1083
+ const metadata = await readJsonFile(metadataPath);
1084
+ if (metadata?.lockId !== lock.lockId) {
1085
+ return;
1086
+ }
1087
+ await fs.rm(lockPath, { recursive: true, force: true });
1088
+ }
305
1089
  async function withLease(worktree, owner, callback) {
306
1090
  await fs.mkdir(worktree.stateRootPath, { recursive: true });
307
1091
  const leasesPath = path.join(worktree.stateRootPath, "lsp-leases.json");
@@ -353,7 +1137,7 @@ async function readPersistedInstance(worktree) {
353
1137
  const instancePath = path.join(worktree.stateRootPath, "lsp-instance.json");
354
1138
  const parsed = await readJsonFile(instancePath);
355
1139
  if (parsed === null ||
356
- parsed.schemaVersion !== LSP_INSTANCE_SCHEMA_VERSION ||
1140
+ (parsed.schemaVersion !== 1 && parsed.schemaVersion !== LSP_INSTANCE_SCHEMA_VERSION) ||
357
1141
  parsed.instance === undefined) {
358
1142
  return null;
359
1143
  }
@@ -363,12 +1147,79 @@ async function persistInstance(worktree, record) {
363
1147
  await fs.mkdir(worktree.stateRootPath, { recursive: true });
364
1148
  await fs.writeFile(path.join(worktree.stateRootPath, "lsp-instance.json"), JSON.stringify(record, null, 2), "utf8");
365
1149
  }
366
- async function validatePersistedInstance(instance) {
1150
+ function validatePersistedInstanceIdentity(instance, expectedIdentity) {
1151
+ const identity = instance.identity;
1152
+ if (identity === undefined || identity === null) {
1153
+ return {
1154
+ reusable: false,
1155
+ reason: "lsp_instance_identity_missing",
1156
+ reasons: ["lsp_instance_identity_missing"],
1157
+ rawPayload: {
1158
+ reused: false,
1159
+ instanceId: instance.instanceId,
1160
+ reason: "lsp_instance_identity_missing",
1161
+ },
1162
+ };
1163
+ }
1164
+ const mismatchChecks = [
1165
+ { field: "worktreeRootPath", reason: "lsp_instance_worktree_mismatch" },
1166
+ { field: "projectRootPath", reason: "lsp_instance_project_mismatch" },
1167
+ { field: "projectKey", reason: "lsp_instance_project_mismatch" },
1168
+ { field: "launcher", reason: "lsp_instance_editor_mismatch" },
1169
+ { field: "editorBinPath", reason: "lsp_instance_editor_mismatch" },
1170
+ { field: "editorVersion", reason: "lsp_instance_editor_mismatch" },
1171
+ { field: "lspSurfaceVersion", reason: "lsp_instance_surface_mismatch" },
1172
+ ];
1173
+ for (const check of mismatchChecks) {
1174
+ if (identity[check.field] !== expectedIdentity[check.field]) {
1175
+ return {
1176
+ reusable: false,
1177
+ reason: check.reason,
1178
+ reasons: [check.reason],
1179
+ rawPayload: {
1180
+ reused: false,
1181
+ instanceId: instance.instanceId,
1182
+ reason: check.reason,
1183
+ field: check.field,
1184
+ persisted: identity[check.field] ?? null,
1185
+ expected: expectedIdentity[check.field] ?? null,
1186
+ persistedIdentity: identity,
1187
+ expectedIdentity,
1188
+ },
1189
+ };
1190
+ }
1191
+ }
1192
+ return { reusable: true };
1193
+ }
1194
+ async function validatePersistedInstance(instance, expectedIdentity) {
1195
+ const identityValidation = validatePersistedInstanceIdentity(instance, expectedIdentity);
1196
+ if (!identityValidation.reusable) {
1197
+ return identityValidation;
1198
+ }
367
1199
  if (instance.launcher === "godot_editor") {
368
1200
  if (instance.pid === null || !isPidAlive(instance.pid)) {
369
1201
  return {
370
1202
  reusable: false,
371
1203
  reason: "lsp_process_not_running",
1204
+ reasons: ["lsp_process_not_running"],
1205
+ rawPayload: {
1206
+ launcher: "godot_editor",
1207
+ reused: false,
1208
+ pid: instance.pid,
1209
+ reason: "lsp_process_not_running",
1210
+ },
1211
+ };
1212
+ }
1213
+ const health = await checkManagedLspProtocolHealth({
1214
+ port: instance.port,
1215
+ projectPath: instance.projectPath,
1216
+ });
1217
+ if (!health.healthy) {
1218
+ return {
1219
+ reusable: false,
1220
+ reason: "lsp_protocol_health_failed",
1221
+ reasons: health.reasons,
1222
+ rawPayload: health.rawPayload,
372
1223
  };
373
1224
  }
374
1225
  return {
@@ -378,6 +1229,8 @@ async function validatePersistedInstance(instance) {
378
1229
  launcher: "godot_editor",
379
1230
  reused: true,
380
1231
  pid: instance.pid,
1232
+ identity: instance.identity,
1233
+ protocolHealth: health.rawPayload,
381
1234
  },
382
1235
  };
383
1236
  }
@@ -386,21 +1239,77 @@ async function validatePersistedInstance(instance) {
386
1239
  return {
387
1240
  reusable: false,
388
1241
  reason: "test_fake_lsp_registry_missing",
1242
+ reasons: ["test_fake_lsp_registry_missing"],
1243
+ rawPayload: {
1244
+ launcher: "test_fake",
1245
+ reused: false,
1246
+ instanceId: instance.instanceId,
1247
+ reason: "test_fake_lsp_registry_missing",
1248
+ },
389
1249
  };
390
1250
  }
391
1251
  return {
392
1252
  reusable: true,
393
1253
  trust: fake.trust,
394
- rawPayload: fake.rawPayload,
1254
+ rawPayload: {
1255
+ ...(fake.rawPayload ?? {}),
1256
+ identity: instance.identity,
1257
+ },
395
1258
  };
396
1259
  }
397
- async function cleanupPersistedInstance(worktree, instance, reason) {
398
- if (instance.launcher === "godot_editor" && instance.pid !== null) {
399
- try {
400
- process.kill(instance.pid, "SIGTERM");
401
- }
402
- catch {
403
- // Best-effort cleanup only.
1260
+ function classifyManagedLspCleanupPolicy(instance) {
1261
+ const idleAfterMs = resolveManagedLspIdleAfterMs();
1262
+ const lastValidatedAt = typeof instance.lastValidatedAt === "string" ? instance.lastValidatedAt : null;
1263
+ const lastValidatedMs = lastValidatedAt === null ? Number.NaN : Date.parse(lastValidatedAt);
1264
+ if (!Number.isFinite(lastValidatedMs)) {
1265
+ return {
1266
+ reason: "lsp_instance_stale_timestamp",
1267
+ reasons: ["lsp_instance_stale_timestamp"],
1268
+ lastValidatedAt,
1269
+ idleAfterMs,
1270
+ elapsedMs: null,
1271
+ };
1272
+ }
1273
+ const elapsedMs = Date.now() - lastValidatedMs;
1274
+ if (elapsedMs >= idleAfterMs) {
1275
+ return {
1276
+ reason: "lsp_instance_idle",
1277
+ reasons: ["lsp_instance_idle"],
1278
+ lastValidatedAt,
1279
+ idleAfterMs,
1280
+ elapsedMs,
1281
+ };
1282
+ }
1283
+ return null;
1284
+ }
1285
+ function resolveManagedLspIdleAfterMs() {
1286
+ const configured = process.env["GDH_TEST_LSP_IDLE_AFTER_MS"] ?? process.env["GDH_MANAGED_LSP_IDLE_AFTER_MS"];
1287
+ if (configured === undefined) {
1288
+ return LSP_INSTANCE_IDLE_AFTER_MS;
1289
+ }
1290
+ const parsed = Number(configured);
1291
+ if (!Number.isFinite(parsed) || parsed < 0) {
1292
+ return LSP_INSTANCE_IDLE_AFTER_MS;
1293
+ }
1294
+ return parsed;
1295
+ }
1296
+ async function cleanupPersistedInstance(worktree, instance, reason, options = {}) {
1297
+ const killProcess = options.killProcess ?? true;
1298
+ let killedPid = false;
1299
+ let killReason = null;
1300
+ if (killProcess && instance.launcher === "godot_editor" && instance.pid !== null) {
1301
+ const identityValidation = options.expectedIdentity === undefined
1302
+ ? { reusable: false }
1303
+ : validatePersistedInstanceIdentity(instance, options.expectedIdentity);
1304
+ if (identityValidation.reusable) {
1305
+ try {
1306
+ process.kill(instance.pid, "SIGTERM");
1307
+ killedPid = true;
1308
+ killReason = reason;
1309
+ }
1310
+ catch {
1311
+ // Best-effort cleanup only.
1312
+ }
404
1313
  }
405
1314
  }
406
1315
  if (instance.launcher === "test_fake") {
@@ -413,12 +1322,15 @@ async function cleanupPersistedInstance(worktree, instance, reason) {
413
1322
  cleanedAt: new Date().toISOString(),
414
1323
  reason,
415
1324
  instanceId: instance.instanceId,
1325
+ killedPid,
1326
+ killReason,
416
1327
  }, null, 2), "utf8");
1328
+ return { killedPid, killReason };
417
1329
  }
418
1330
  async function launchManagedInstance(worktree, projectPath) {
419
1331
  const testMode = process.env["GDH_TEST_LSP_MODE"];
420
1332
  if (testMode) {
421
- return launchTestFakeLsp(testMode);
1333
+ return await launchTestFakeLsp(testMode, worktree, projectPath);
422
1334
  }
423
1335
  const godotBin = await resolveConfiguredGodotEditorBin({
424
1336
  targetPath: worktree.rootPath,
@@ -428,10 +1340,20 @@ async function launchManagedInstance(worktree, projectPath) {
428
1340
  return {
429
1341
  unavailable: {
430
1342
  reason: "godot_editor_not_configured",
1343
+ reasons: ["godot_editor_not_configured"],
431
1344
  summary: "authoring.lsp is unavailable because no machine-local Godot editor path is configured for this target.",
432
1345
  },
433
1346
  };
434
1347
  }
1348
+ const version = await probeGodotEditorVersion(godotBin);
1349
+ const identity = await createManagedLspInstanceIdentity({
1350
+ worktree,
1351
+ projectPath,
1352
+ launcher: "godot_editor",
1353
+ editorBinPath: godotBin,
1354
+ editorVersion: version.version,
1355
+ editorVersionReason: version.reason,
1356
+ });
435
1357
  const port = await allocateDynamicPort();
436
1358
  const command = [
437
1359
  godotBin,
@@ -447,7 +1369,16 @@ async function launchManagedInstance(worktree, projectPath) {
447
1369
  stdio: "ignore",
448
1370
  cwd: projectPath,
449
1371
  });
450
- const launchOutcome = await new Promise((resolve) => {
1372
+ if (child.pid === undefined) {
1373
+ return {
1374
+ unavailable: {
1375
+ reason: "godot_editor_launch_failed",
1376
+ reasons: ["godot_editor_launch_failed"],
1377
+ summary: "authoring.lsp could not launch the configured Godot editor.",
1378
+ },
1379
+ };
1380
+ }
1381
+ const launchFailure = new Promise((resolve) => {
451
1382
  let settled = false;
452
1383
  const settle = (result) => {
453
1384
  if (!settled) {
@@ -457,25 +1388,53 @@ async function launchManagedInstance(worktree, projectPath) {
457
1388
  };
458
1389
  child.once("error", (error) => {
459
1390
  settle({
460
- ok: false,
1391
+ failed: true,
461
1392
  reason: "godot_editor_launch_failed",
462
1393
  summary: `authoring.lsp could not launch the configured Godot editor: ${error.message}`,
463
1394
  });
464
1395
  });
465
- setImmediate(() => {
1396
+ child.once("exit", (code, signal) => {
466
1397
  settle({
467
- ok: true,
468
- pid: child.pid ?? null,
1398
+ failed: true,
1399
+ reason: "godot_editor_exited_before_lsp_ready",
1400
+ summary: `The configured Godot editor exited before authoring.lsp became protocol-ready (code ${code ?? "null"}, signal ${signal ?? "null"}).`,
469
1401
  });
470
1402
  });
471
1403
  });
472
- if (!launchOutcome.ok || launchOutcome.pid === null) {
1404
+ const health = await Promise.race([
1405
+ checkManagedLspProtocolHealth({ port, projectPath }),
1406
+ launchFailure,
1407
+ ]);
1408
+ if ("failed" in health) {
1409
+ if (health.failed) {
1410
+ return {
1411
+ unavailable: {
1412
+ reason: health.reason,
1413
+ reasons: [health.reason],
1414
+ summary: health.summary,
1415
+ },
1416
+ };
1417
+ }
473
1418
  return {
474
1419
  unavailable: {
475
1420
  reason: "godot_editor_launch_failed",
476
- summary: launchOutcome.ok === false
477
- ? launchOutcome.summary
478
- : "authoring.lsp could not launch the configured Godot editor.",
1421
+ reasons: ["godot_editor_launch_failed"],
1422
+ summary: "authoring.lsp could not launch the configured Godot editor.",
1423
+ },
1424
+ };
1425
+ }
1426
+ if (!health.healthy) {
1427
+ try {
1428
+ process.kill(child.pid, "SIGTERM");
1429
+ }
1430
+ catch {
1431
+ // Best-effort cleanup only.
1432
+ }
1433
+ return {
1434
+ unavailable: {
1435
+ reason: health.reason,
1436
+ reasons: health.reasons,
1437
+ summary: health.summary,
479
1438
  },
480
1439
  };
481
1440
  }
@@ -484,29 +1443,132 @@ async function launchManagedInstance(worktree, projectPath) {
484
1443
  instanceId: randomUUID(),
485
1444
  launcher: "godot_editor",
486
1445
  port,
487
- pid: launchOutcome.pid,
1446
+ pid: child.pid,
488
1447
  command,
489
1448
  trust: "trusted",
1449
+ identity,
490
1450
  validationMode: "unimplemented",
491
1451
  diagnostics: [],
492
1452
  rawPayload: {
493
1453
  launcher: "godot_editor",
494
1454
  command,
495
- pid: launchOutcome.pid,
1455
+ pid: child.pid,
1456
+ identity,
1457
+ protocolHealth: health.rawPayload,
1458
+ },
1459
+ };
1460
+ }
1461
+ async function checkManagedLspProtocolHealth(input) {
1462
+ const startedAt = Date.now();
1463
+ const deadline = startedAt + (input.totalTimeoutMs ?? MANAGED_LSP_HEALTH_TOTAL_TIMEOUT_MS);
1464
+ let attempts = 0;
1465
+ let lastFailure = null;
1466
+ while (Date.now() < deadline) {
1467
+ attempts += 1;
1468
+ try {
1469
+ const client = await connectGodotLspClient({
1470
+ port: input.port,
1471
+ connectTimeoutMs: input.connectTimeoutMs ?? MANAGED_LSP_HEALTH_CONNECT_TIMEOUT_MS,
1472
+ requestTimeoutMs: input.requestTimeoutMs ?? MANAGED_LSP_HEALTH_REQUEST_TIMEOUT_MS,
1473
+ });
1474
+ try {
1475
+ const initialize = await client.initialize({
1476
+ rootUri: pathToFileURL(input.projectPath).href,
1477
+ });
1478
+ await client.initialized();
1479
+ await client.close();
1480
+ return {
1481
+ healthy: true,
1482
+ rawPayload: {
1483
+ protocol: "lsp",
1484
+ initialized: true,
1485
+ port: input.port,
1486
+ attempts,
1487
+ elapsedMs: Date.now() - startedAt,
1488
+ serverInfo: initialize.serverInfo ?? null,
1489
+ capabilities: initialize.capabilities,
1490
+ },
1491
+ };
1492
+ }
1493
+ catch (error) {
1494
+ await client.close();
1495
+ throw error;
1496
+ }
1497
+ }
1498
+ catch (error) {
1499
+ const coerced = coerceLspHealthFailure(error);
1500
+ lastFailure = coerced;
1501
+ if (!isRetryableLspHealthFailure(coerced.reason)) {
1502
+ break;
1503
+ }
1504
+ const remainingMs = deadline - Date.now();
1505
+ if (remainingMs <= 0) {
1506
+ break;
1507
+ }
1508
+ await sleep(Math.min(MANAGED_LSP_HEALTH_RETRY_DELAY_MS, remainingMs));
1509
+ }
1510
+ }
1511
+ const reason = lastFailure?.reason ?? "lsp_tcp_timeout";
1512
+ return {
1513
+ healthy: false,
1514
+ reason: "lsp_protocol_health_failed",
1515
+ reasons: dedupe(["lsp_protocol_health_failed", reason]),
1516
+ summary: `authoring.lsp did not become protocol-ready: ${lastFailure?.message ?? "timed out waiting for the LSP endpoint."}`,
1517
+ rawPayload: {
1518
+ protocol: "lsp",
1519
+ initialized: false,
1520
+ port: input.port,
1521
+ attempts,
1522
+ elapsedMs: Date.now() - startedAt,
1523
+ failureReason: reason,
1524
+ failureDetails: lastFailure?.details ?? {},
496
1525
  },
497
1526
  };
498
1527
  }
499
- async function launchTestFakeLsp(mode) {
1528
+ function coerceLspHealthFailure(error) {
1529
+ if (error instanceof GdhLspClientError) {
1530
+ return {
1531
+ reason: error.reason,
1532
+ message: error.message,
1533
+ details: error.details,
1534
+ };
1535
+ }
1536
+ return {
1537
+ reason: "lsp_connection_closed",
1538
+ message: error instanceof Error ? error.message : "LSP health check failed.",
1539
+ details: {},
1540
+ };
1541
+ }
1542
+ function isRetryableLspHealthFailure(reason) {
1543
+ return reason === "lsp_tcp_connection_failed" || reason === "lsp_tcp_timeout";
1544
+ }
1545
+ async function sleep(ms) {
1546
+ await new Promise((resolve) => setTimeout(resolve, ms));
1547
+ }
1548
+ async function launchTestFakeLsp(mode, worktree, projectPath) {
500
1549
  const normalizedMode = normalizeTestMode(mode);
501
1550
  const port = await allocateDynamicPort();
502
1551
  const instanceId = randomUUID();
1552
+ const identity = await createManagedLspInstanceIdentity({
1553
+ worktree,
1554
+ projectPath,
1555
+ launcher: "test_fake",
1556
+ editorBinPath: `test_fake:${normalizedMode}`,
1557
+ editorVersion: normalizedMode,
1558
+ editorVersionReason: null,
1559
+ });
503
1560
  const diagnostics = normalizedMode === "diagnostics"
504
1561
  ? [
505
1562
  {
506
1563
  message: "Fake LSP reported a syntax error for integration coverage.",
507
- path: "scenes/main.tscn",
1564
+ path: "scripts/fake.gd",
1565
+ line: null,
1566
+ column: null,
1567
+ endLine: null,
1568
+ endColumn: null,
508
1569
  severity: "warning",
509
1570
  source: "godot_lsp",
1571
+ raw: null,
510
1572
  },
511
1573
  ]
512
1574
  : [];
@@ -528,11 +1590,13 @@ async function launchTestFakeLsp(mode) {
528
1590
  pid: null,
529
1591
  command: ["test_fake_lsp", normalizedMode],
530
1592
  trust: normalizedMode === "degraded" ? "degraded" : "trusted",
1593
+ identity,
531
1594
  validationMode: normalizedMode,
532
1595
  diagnostics,
533
1596
  rawPayload: {
534
1597
  ...rawPayload,
535
1598
  testInstanceId: instanceId,
1599
+ identity,
536
1600
  },
537
1601
  };
538
1602
  }
@@ -542,6 +1606,40 @@ function normalizeTestMode(mode) {
542
1606
  }
543
1607
  return "passed";
544
1608
  }
1609
+ function createLifecycleCommandResult(input) {
1610
+ return {
1611
+ targetPath: input.targetPath,
1612
+ capability: "authoring.lsp",
1613
+ command: input.command,
1614
+ status: input.status,
1615
+ summary: input.summary,
1616
+ reasons: dedupe(input.reasons),
1617
+ worktree: input.worktree,
1618
+ lifecycle: {
1619
+ status: input.status,
1620
+ instanceId: input.instance?.instanceId ?? null,
1621
+ launcher: input.instance?.launcher ?? null,
1622
+ pid: input.instance?.pid ?? null,
1623
+ port: input.instance?.port ?? null,
1624
+ },
1625
+ transport: {
1626
+ status: input.transportStatus,
1627
+ reasons: dedupe(input.transportReasons),
1628
+ rawPayload: input.transportRawPayload,
1629
+ },
1630
+ diagnostics: {
1631
+ status: "unimplemented",
1632
+ summary: "Real Godot diagnostic collection remains unavailable until GDH opens files and consumes publishDiagnostics.",
1633
+ },
1634
+ cleanup: {
1635
+ status: input.cleanupStatus,
1636
+ reason: input.cleanupReason,
1637
+ killedPid: input.killedPid,
1638
+ },
1639
+ recommendations: input.recommendations,
1640
+ statusResult: input.statusResult,
1641
+ };
1642
+ }
545
1643
  function createStatusResult(input) {
546
1644
  return {
547
1645
  targetPath: input.targetPath,