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