oh-my-opencode 5.0.0-beta.30 → 5.0.0-beta.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +75 -26
- package/dist/cli-node/index.js +75 -26
- package/dist/config/schema/background-task.d.ts +1 -0
- package/dist/config/schema/oh-my-opencode-config.d.ts +1 -0
- package/dist/features/background-agent/constants.d.ts +0 -7
- package/dist/features/background-agent/manager.d.ts +2 -1
- package/dist/features/background-agent/parent-wake-dispatched-tracker.d.ts +0 -1
- package/dist/features/background-agent/subagent-spawn-limits.d.ts +8 -0
- package/dist/features/task-toast-manager/manager.d.ts +0 -24
- package/dist/hooks/anthropic-context-window-limit-recovery/message-storage-directory.d.ts +0 -3
- package/dist/hooks/anthropic-context-window-limit-recovery/state.d.ts +1 -0
- package/dist/hooks/anthropic-context-window-limit-recovery/storage/empty-messages.d.ts +0 -1
- package/dist/hooks/claude-code-hooks/session-hook-state.d.ts +1 -0
- package/dist/hooks/claude-code-hooks/stop.d.ts +2 -0
- package/dist/hooks/claude-code-hooks/todo.d.ts +0 -1
- package/dist/hooks/claude-code-hooks/transcript.d.ts +2 -0
- package/dist/hooks/fsync-skip-warning/index.d.ts +4 -0
- package/dist/hooks/hashline-edit-diff-enhancer/hook.d.ts +1 -0
- package/dist/hooks/hashline-edit-diff-enhancer/pending-captures.d.ts +13 -0
- package/dist/hooks/keyword-detector/hook.d.ts +7 -0
- package/dist/hooks/keyword-detector/ultrawork/source-detector.d.ts +0 -1
- package/dist/hooks/model-fallback/hook.d.ts +1 -1
- package/dist/index.js +439 -218
- package/dist/oh-my-opencode.schema.json +5 -0
- package/dist/plugin-dispose.d.ts +3 -0
- package/dist/shared/agent-tool-restrictions.d.ts +0 -1
- package/dist/tools/delegate-task/sync-session-cleanup.d.ts +3 -0
- package/dist/tools/delegate-task/sync-session-poller.d.ts +1 -0
- package/dist/tools/delegate-task/types.d.ts +1 -0
- package/dist/tui.js +56 -3
- package/package.json +15 -15
- package/packages/git-bash-mcp/dist/cli.js +1 -0
- package/packages/lsp-core/src/lsp/client-wrapper.test.ts +97 -0
- package/packages/lsp-core/src/lsp/client-wrapper.ts +21 -6
- package/packages/lsp-core/src/lsp/constants.ts +5 -0
- package/packages/lsp-core/src/lsp/errors.ts +15 -0
- package/packages/lsp-core/src/lsp/manager-lifecycle.test.ts +262 -0
- package/packages/lsp-core/src/lsp/manager.ts +106 -13
- package/packages/lsp-core/src/lsp/workspace-markers.ts +18 -1
- package/packages/lsp-core/src/mcp.ts +3 -0
- package/packages/lsp-daemon/dist/cli.js +114 -18
- package/packages/lsp-daemon/dist/client.js +114 -18
- package/packages/lsp-daemon/dist/index.js +114 -18
- package/packages/lsp-daemon/dist/proxy.js +3 -0
- package/packages/lsp-tools-mcp/dist/cli.js +114 -18
- package/packages/lsp-tools-mcp/dist/lsp/manager.js +93 -12
- package/packages/lsp-tools-mcp/dist/mcp.js +114 -18
- package/packages/lsp-tools-mcp/dist/tools.js +113 -18
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +31 -2
- package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +31 -2
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/src/mcp-unavailable.ts +12 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-unavailable-idle.test.ts +47 -0
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +3 -3
- package/packages/omo-codex/plugin/components/lsp/dist/cli.js +113 -18
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/AGENTS.md +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/checkpoint.js +2 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +100 -54
- package/packages/omo-codex/plugin/components/ulw-loop/dist/codex-goal-instruction.d.ts +2 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/codex-goal-instruction.js +9 -6
- package/packages/omo-codex/plugin/components/ulw-loop/dist/quality-gate.d.ts +4 -2
- package/packages/omo-codex/plugin/components/ulw-loop/dist/quality-gate.js +7 -10
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +3 -2
- package/packages/omo-codex/plugin/components/ulw-loop/dist/surface.d.ts +15 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/surface.js +50 -0
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts +2 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts +16 -5
- package/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts +11 -14
- package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +3 -2
- package/packages/omo-codex/plugin/components/ulw-loop/src/surface.ts +73 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/checkpoint-final.test.ts +44 -1
- package/packages/omo-codex/plugin/components/ulw-loop/test/codex-goal-instruction.test.ts +25 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/quality-gate-builder.ts +7 -3
- package/packages/omo-codex/plugin/components/ulw-loop/test/quality-gate-roles.test.ts +45 -2
- package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-guard.test.ts +20 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/surface.test.ts +74 -0
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +13 -13
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
- package/dist/hooks/ralph-loop/completion-promise-detector-test-input.d.ts +0 -11
- package/dist/hooks/ralph-loop/loop-session-recovery.d.ts +0 -7
- package/dist/tools/call-omo-agent/message-storage-directory.d.ts +0 -1
- package/dist/tools/delegate-task/model-string-parser.d.ts +0 -1
- package/packages/lsp-tools-mcp/dist/lsp/cleanup-errors.d.ts +0 -1
- package/packages/lsp-tools-mcp/dist/lsp/constants.d.ts +0 -1
- package/packages/lsp-tools-mcp/dist/lsp/language-mappings.d.ts +0 -1
- package/packages/lsp-tools-mcp/dist/lsp/process-signal-cleanup.d.ts +0 -1
- package/packages/lsp-tools-mcp/dist/missing-dependency-result.d.ts +0 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { LspClient } from "./client.js";
|
|
4
|
+
import { LspManager } from "./manager.js";
|
|
5
|
+
import type { ResolvedServer } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const SERVER: ResolvedServer = {
|
|
8
|
+
id: "typescript",
|
|
9
|
+
command: ["typescript-language-server", "--stdio"],
|
|
10
|
+
extensions: [".ts"],
|
|
11
|
+
priority: 1,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Simulates a stop that lingers (production stops reach ~21s), which is the behavior under test. */
|
|
15
|
+
const SLOW_STOP_MS = 50;
|
|
16
|
+
|
|
17
|
+
interface RecordingClientOptions {
|
|
18
|
+
readonly stopDelayMs: number;
|
|
19
|
+
readonly initializeError?: Error;
|
|
20
|
+
readonly alive: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class RecordingFakeClient {
|
|
24
|
+
alive: boolean;
|
|
25
|
+
stopCallCount = 0;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
readonly root: string,
|
|
29
|
+
private readonly log: EventLog,
|
|
30
|
+
private readonly options: RecordingClientOptions,
|
|
31
|
+
) {
|
|
32
|
+
this.alive = options.alive;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async start(): Promise<void> {
|
|
36
|
+
this.log.push(`spawn:${this.root}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async initialize(): Promise<void> {
|
|
40
|
+
if (this.options.initializeError !== undefined) {
|
|
41
|
+
throw this.options.initializeError;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
isAlive(): boolean {
|
|
46
|
+
return this.alive;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
command(): string[] {
|
|
50
|
+
return [...SERVER.command];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async stop(): Promise<void> {
|
|
54
|
+
this.stopCallCount += 1;
|
|
55
|
+
this.log.push(`stop-start:${this.root}`);
|
|
56
|
+
await delay(this.options.stopDelayMs);
|
|
57
|
+
this.alive = false;
|
|
58
|
+
this.log.push(`stop-end:${this.root}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class EventLog {
|
|
63
|
+
readonly entries: string[] = [];
|
|
64
|
+
private waiters: Array<{ readonly event: string; readonly resolve: () => void }> = [];
|
|
65
|
+
|
|
66
|
+
push(event: string): void {
|
|
67
|
+
this.entries.push(event);
|
|
68
|
+
const due = this.waiters.filter((waiter) => waiter.event === event);
|
|
69
|
+
if (due.length === 0) return;
|
|
70
|
+
this.waiters = this.waiters.filter((waiter) => waiter.event !== event);
|
|
71
|
+
for (const waiter of due) waiter.resolve();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
waitFor(event: string): Promise<void> {
|
|
75
|
+
if (this.entries.includes(event)) return Promise.resolve();
|
|
76
|
+
return new Promise<void>((resolve) => {
|
|
77
|
+
this.waiters.push({ event, resolve });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function delay(ms: number): Promise<void> {
|
|
83
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface HarnessOptions {
|
|
87
|
+
readonly maxResidentClients?: number;
|
|
88
|
+
readonly stopDelayMs?: number;
|
|
89
|
+
/** Leading generations whose initialize() rejects; later generations start cleanly. */
|
|
90
|
+
readonly initializeFailures?: number;
|
|
91
|
+
/** Generations at or above this index report alive; earlier ones emulate a crash-looping server. */
|
|
92
|
+
readonly aliveFromGeneration?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function managerHarness(options: HarnessOptions = {}): {
|
|
96
|
+
readonly manager: LspManager;
|
|
97
|
+
readonly clients: RecordingFakeClient[];
|
|
98
|
+
readonly log: EventLog;
|
|
99
|
+
readonly clock: { value: number };
|
|
100
|
+
} {
|
|
101
|
+
const clients: RecordingFakeClient[] = [];
|
|
102
|
+
const log = new EventLog();
|
|
103
|
+
const clock = { value: 1_000 };
|
|
104
|
+
const generations = new Map<string, number>();
|
|
105
|
+
const aliveFrom = options.aliveFromGeneration ?? 1;
|
|
106
|
+
const stopDelayMs = options.stopDelayMs ?? 0;
|
|
107
|
+
const initializeFailures = options.initializeFailures ?? 0;
|
|
108
|
+
const manager = new LspManager({
|
|
109
|
+
maxResidentClients: options.maxResidentClients ?? 6,
|
|
110
|
+
reaperIntervalMs: 60_000,
|
|
111
|
+
idleTimeoutMs: 60_000_000,
|
|
112
|
+
now: () => clock.value,
|
|
113
|
+
clientFactory: (root) => {
|
|
114
|
+
const generation = (generations.get(root) ?? 0) + 1;
|
|
115
|
+
generations.set(root, generation);
|
|
116
|
+
const client = new RecordingFakeClient(root, log, {
|
|
117
|
+
stopDelayMs,
|
|
118
|
+
alive: generation >= aliveFrom,
|
|
119
|
+
...(generation <= initializeFailures ? { initializeError: new Error("initialize boom") } : {}),
|
|
120
|
+
});
|
|
121
|
+
clients.push(client);
|
|
122
|
+
return client as unknown as LspClient;
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
return { manager, clients, log, clock };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("LspManager pending-stop tombstones", () => {
|
|
129
|
+
it("#given a slow-stopping client evicted at the resident cap #when the evicted root is re-acquired while the stop is pending #then the replacement spawns only after the old client stopped", async () => {
|
|
130
|
+
// given
|
|
131
|
+
const { manager, log } = managerHarness({ maxResidentClients: 1, stopDelayMs: SLOW_STOP_MS });
|
|
132
|
+
try {
|
|
133
|
+
await manager.getClient("/root-a", SERVER);
|
|
134
|
+
manager.releaseClient("/root-a", SERVER.id);
|
|
135
|
+
|
|
136
|
+
// when: admitting root-b synchronously evicts idle root-a (void stop), and root-a is
|
|
137
|
+
// re-acquired in the same window while the evicted client's stop is still pending.
|
|
138
|
+
const rootBPromise = manager.getClient("/root-b", SERVER);
|
|
139
|
+
const rootAPromise = manager.getClient("/root-a", SERVER);
|
|
140
|
+
await Promise.all([rootBPromise, rootAPromise]);
|
|
141
|
+
manager.releaseClient("/root-b", SERVER.id);
|
|
142
|
+
manager.releaseClient("/root-a", SERVER.id);
|
|
143
|
+
|
|
144
|
+
// then
|
|
145
|
+
expect(log.entries.filter((event) => event.endsWith("/root-a"))).toEqual([
|
|
146
|
+
"spawn:/root-a",
|
|
147
|
+
"stop-start:/root-a",
|
|
148
|
+
"stop-end:/root-a",
|
|
149
|
+
"spawn:/root-a",
|
|
150
|
+
]);
|
|
151
|
+
} finally {
|
|
152
|
+
await manager.stopAll();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("#given an invalidated client whose stop is still pending #when the same root is re-acquired #then the replacement spawns only after the invalidated client stopped", async () => {
|
|
157
|
+
// given
|
|
158
|
+
const { manager, log } = managerHarness({ stopDelayMs: SLOW_STOP_MS });
|
|
159
|
+
try {
|
|
160
|
+
await manager.getClient("/root-a", SERVER);
|
|
161
|
+
manager.releaseClient("/root-a", SERVER.id);
|
|
162
|
+
|
|
163
|
+
// when
|
|
164
|
+
manager.invalidateClient("/root-a", SERVER.id);
|
|
165
|
+
await manager.getClient("/root-a", SERVER);
|
|
166
|
+
|
|
167
|
+
// then
|
|
168
|
+
expect(log.entries).toEqual([
|
|
169
|
+
"spawn:/root-a",
|
|
170
|
+
"stop-start:/root-a",
|
|
171
|
+
"stop-end:/root-a",
|
|
172
|
+
"spawn:/root-a",
|
|
173
|
+
]);
|
|
174
|
+
} finally {
|
|
175
|
+
await manager.stopAll();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("#given a client whose initialization failed while its stop is pending #when another getClient races the stop #then the replacement spawns only after the failed client stopped", async () => {
|
|
180
|
+
// given
|
|
181
|
+
const { manager, log } = managerHarness({
|
|
182
|
+
stopDelayMs: SLOW_STOP_MS,
|
|
183
|
+
initializeFailures: 1,
|
|
184
|
+
});
|
|
185
|
+
try {
|
|
186
|
+
const failing = manager.getClient("/root-fail", SERVER);
|
|
187
|
+
|
|
188
|
+
// when: the second acquisition starts once the first one's stop is provably in flight.
|
|
189
|
+
await log.waitFor("stop-start:/root-fail");
|
|
190
|
+
const racing = manager.getClient("/root-fail", SERVER);
|
|
191
|
+
await expect(failing).rejects.toThrow("initialize boom");
|
|
192
|
+
await racing;
|
|
193
|
+
manager.releaseClient("/root-fail", SERVER.id);
|
|
194
|
+
|
|
195
|
+
// then
|
|
196
|
+
expect(log.entries).toEqual([
|
|
197
|
+
"spawn:/root-fail",
|
|
198
|
+
"stop-start:/root-fail",
|
|
199
|
+
"stop-end:/root-fail",
|
|
200
|
+
"spawn:/root-fail",
|
|
201
|
+
]);
|
|
202
|
+
} finally {
|
|
203
|
+
await manager.stopAll();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("LspManager dead-client respawn budget", () => {
|
|
209
|
+
it("#given a crash-looping client that is dead on every acquisition #when getClient is called repeatedly #then respawn attempts stay within the retry budget", async () => {
|
|
210
|
+
// given
|
|
211
|
+
const { manager, clients } = managerHarness({ aliveFromGeneration: Number.POSITIVE_INFINITY });
|
|
212
|
+
try {
|
|
213
|
+
// when
|
|
214
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
215
|
+
await manager
|
|
216
|
+
.getClient("/root-crash", SERVER)
|
|
217
|
+
.then(
|
|
218
|
+
() => manager.releaseClient("/root-crash", SERVER.id),
|
|
219
|
+
() => undefined,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// then: one initial generation plus at most two budgeted respawns.
|
|
224
|
+
expect(clients.length).toBeLessThanOrEqual(3);
|
|
225
|
+
} finally {
|
|
226
|
+
await manager.stopAll();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("#given the respawn budget is exhausted #when getClient is called again #then it rejects with a budget error instead of respawning", async () => {
|
|
231
|
+
// given
|
|
232
|
+
const { manager } = managerHarness({ aliveFromGeneration: Number.POSITIVE_INFINITY });
|
|
233
|
+
try {
|
|
234
|
+
await expect(manager.getClient("/root-crash", SERVER)).rejects.toThrow(/respawn budget/);
|
|
235
|
+
|
|
236
|
+
// when / then
|
|
237
|
+
await expect(manager.getClient("/root-crash", SERVER)).rejects.toThrow(/respawn budget/);
|
|
238
|
+
} finally {
|
|
239
|
+
await manager.stopAll();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("#given the budget was exhausted but the cooldown elapsed and the server recovered #when getClient is called #then it spawns again and resets the budget on a healthy generation", async () => {
|
|
244
|
+
// given: generations one and two crash, the third is healthy.
|
|
245
|
+
const { manager, clients, clock } = managerHarness({ aliveFromGeneration: 3 });
|
|
246
|
+
try {
|
|
247
|
+
await expect(manager.getClient("/root-crash", SERVER)).rejects.toThrow(/respawn budget/);
|
|
248
|
+
|
|
249
|
+
// when: cooldown elapses (60s default) and the server stays alive again.
|
|
250
|
+
clock.value += 60_001;
|
|
251
|
+
const recovered = await manager.getClient("/root-crash", SERVER);
|
|
252
|
+
manager.releaseClient("/root-crash", SERVER.id);
|
|
253
|
+
|
|
254
|
+
// then
|
|
255
|
+
expect(clients.length).toBe(3);
|
|
256
|
+
await expect(manager.getClient("/root-crash", SERVER)).resolves.toBe(recovered);
|
|
257
|
+
manager.releaseClient("/root-crash", SERVER.id);
|
|
258
|
+
} finally {
|
|
259
|
+
await manager.stopAll();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
|
|
2
2
|
import { LspClient } from "./client.js";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CLIENT_RESPAWN_COOLDOWN_MS,
|
|
5
|
+
CLIENT_RESPAWN_RETRY_LIMIT,
|
|
6
|
+
IDLE_TIMEOUT_MS,
|
|
7
|
+
INIT_TIMEOUT_MS,
|
|
8
|
+
MAX_RESIDENT_CLIENTS,
|
|
9
|
+
REAPER_INTERVAL_MS,
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
import { LspClientRespawnBudgetExceededError } from "./errors.js";
|
|
4
12
|
import { installProcessSignalCleanup } from "./process-signal-cleanup.js";
|
|
5
13
|
import type { ResolvedServer } from "./types.js";
|
|
6
14
|
|
|
@@ -14,6 +22,11 @@ interface ManagedClient {
|
|
|
14
22
|
initializingSince: number | null;
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
interface RespawnBudget {
|
|
26
|
+
deadGenerations: number;
|
|
27
|
+
exhaustedAt: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
export interface ClientSnapshot {
|
|
18
31
|
root: string;
|
|
19
32
|
serverId: string;
|
|
@@ -75,6 +88,9 @@ function awaitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined
|
|
|
75
88
|
|
|
76
89
|
export class LspManager {
|
|
77
90
|
private readonly clients = new Map<string, ManagedClient>();
|
|
91
|
+
/** In-flight stops per key; getClient awaits these instead of spawning a duplicate client. */
|
|
92
|
+
private readonly pendingStops = new Map<string, Promise<void>>();
|
|
93
|
+
private readonly respawnBudgets = new Map<string, RespawnBudget>();
|
|
78
94
|
private reaperHandle: NodeJS.Timeout | null = null;
|
|
79
95
|
private signalDisposer: (() => void) | null = null;
|
|
80
96
|
private disposed = false;
|
|
@@ -112,6 +128,50 @@ export class LspManager {
|
|
|
112
128
|
return `${root}::${serverId}`;
|
|
113
129
|
}
|
|
114
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Deletes-then-stops with a tombstone: the pending stop is recorded so a concurrent getClient
|
|
133
|
+
* for the same key awaits it instead of spawning a duplicate client while the old one lingers
|
|
134
|
+
* (production stops reach ~21s). The tombstone removes itself once the stop settles.
|
|
135
|
+
*/
|
|
136
|
+
private tombstoneStop(key: string, client: LspClient): Promise<void> {
|
|
137
|
+
const stop = stopClientBestEffort(client);
|
|
138
|
+
this.pendingStops.set(key, stop);
|
|
139
|
+
void stop.finally(() => {
|
|
140
|
+
if (this.pendingStops.get(key) === stop) {
|
|
141
|
+
this.pendingStops.delete(key);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return stop;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private recordDeadGeneration(key: string): void {
|
|
148
|
+
let budget = this.respawnBudgets.get(key);
|
|
149
|
+
if (budget === undefined) {
|
|
150
|
+
budget = { deadGenerations: 0, exhaustedAt: null };
|
|
151
|
+
this.respawnBudgets.set(key, budget);
|
|
152
|
+
}
|
|
153
|
+
budget.deadGenerations += 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private markHealthyGeneration(key: string): void {
|
|
157
|
+
this.respawnBudgets.delete(key);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Throws while the respawn budget is spent and the cooldown has not elapsed. */
|
|
161
|
+
private ensureRespawnAllowed(key: string, root: string, serverId: string): void {
|
|
162
|
+
const budget = this.respawnBudgets.get(key);
|
|
163
|
+
if (budget === undefined || budget.deadGenerations < CLIENT_RESPAWN_RETRY_LIMIT) return;
|
|
164
|
+
if (budget.exhaustedAt === null) {
|
|
165
|
+
budget.exhaustedAt = this.now();
|
|
166
|
+
throw new LspClientRespawnBudgetExceededError(serverId, root, CLIENT_RESPAWN_RETRY_LIMIT);
|
|
167
|
+
}
|
|
168
|
+
if (this.now() - budget.exhaustedAt < CLIENT_RESPAWN_COOLDOWN_MS) {
|
|
169
|
+
throw new LspClientRespawnBudgetExceededError(serverId, root, CLIENT_RESPAWN_RETRY_LIMIT);
|
|
170
|
+
}
|
|
171
|
+
budget.deadGenerations = 0;
|
|
172
|
+
budget.exhaustedAt = null;
|
|
173
|
+
}
|
|
174
|
+
|
|
115
175
|
private reapStale(): void {
|
|
116
176
|
const t = this.now();
|
|
117
177
|
for (const [key, managed] of this.clients) {
|
|
@@ -120,8 +180,8 @@ export class LspManager {
|
|
|
120
180
|
managed.initializingSince !== null &&
|
|
121
181
|
t - managed.initializingSince > this.initTimeoutMs
|
|
122
182
|
) {
|
|
123
|
-
void stopClientBestEffort(managed.client);
|
|
124
183
|
this.clients.delete(key);
|
|
184
|
+
void this.tombstoneStop(key, managed.client);
|
|
125
185
|
continue;
|
|
126
186
|
}
|
|
127
187
|
|
|
@@ -131,8 +191,8 @@ export class LspManager {
|
|
|
131
191
|
managed.pendingWaiters === 0 &&
|
|
132
192
|
t - managed.lastUsedAt > this.idleTimeoutMs
|
|
133
193
|
) {
|
|
134
|
-
void stopClientBestEffort(managed.client);
|
|
135
194
|
this.clients.delete(key);
|
|
195
|
+
void this.tombstoneStop(key, managed.client);
|
|
136
196
|
}
|
|
137
197
|
}
|
|
138
198
|
}
|
|
@@ -148,7 +208,7 @@ export class LspManager {
|
|
|
148
208
|
const victim = this.leastRecentlyUsedIdleClient();
|
|
149
209
|
if (!victim) return;
|
|
150
210
|
this.clients.delete(victim.key);
|
|
151
|
-
void
|
|
211
|
+
void this.tombstoneStop(victim.key, victim.managed.client);
|
|
152
212
|
}
|
|
153
213
|
}
|
|
154
214
|
|
|
@@ -171,7 +231,7 @@ export class LspManager {
|
|
|
171
231
|
this.clients.get(key) === managed
|
|
172
232
|
) {
|
|
173
233
|
this.clients.delete(key);
|
|
174
|
-
await
|
|
234
|
+
await this.tombstoneStop(key, managed.client);
|
|
175
235
|
}
|
|
176
236
|
}
|
|
177
237
|
|
|
@@ -182,6 +242,16 @@ export class LspManager {
|
|
|
182
242
|
signal?.throwIfAborted();
|
|
183
243
|
|
|
184
244
|
const key = this.getKey(root, server.id);
|
|
245
|
+
for (;;) {
|
|
246
|
+
const pendingStop = this.pendingStops.get(key);
|
|
247
|
+
if (pendingStop === undefined) break;
|
|
248
|
+
await awaitWithSignal(pendingStop, signal);
|
|
249
|
+
signal?.throwIfAborted();
|
|
250
|
+
}
|
|
251
|
+
if (this.disposed) {
|
|
252
|
+
throw new Error("LspManager has been disposed");
|
|
253
|
+
}
|
|
254
|
+
|
|
185
255
|
let managed = this.clients.get(key);
|
|
186
256
|
|
|
187
257
|
if (managed) {
|
|
@@ -191,8 +261,8 @@ export class LspManager {
|
|
|
191
261
|
managed.initializingSince !== null &&
|
|
192
262
|
t - managed.initializingSince > this.initTimeoutMs
|
|
193
263
|
) {
|
|
194
|
-
await stopClientBestEffort(managed.client);
|
|
195
264
|
this.clients.delete(key);
|
|
265
|
+
await this.tombstoneStop(key, managed.client);
|
|
196
266
|
managed = undefined;
|
|
197
267
|
}
|
|
198
268
|
}
|
|
@@ -216,17 +286,22 @@ export class LspManager {
|
|
|
216
286
|
}
|
|
217
287
|
|
|
218
288
|
if (!managed.client.isAlive()) {
|
|
219
|
-
|
|
220
|
-
this.clients.
|
|
289
|
+
this.recordDeadGeneration(key);
|
|
290
|
+
if (this.clients.get(key) === managed) {
|
|
291
|
+
this.clients.delete(key);
|
|
292
|
+
}
|
|
293
|
+
await this.tombstoneStop(key, managed.client);
|
|
221
294
|
return this.getClient(root, server, signal);
|
|
222
295
|
}
|
|
223
296
|
|
|
297
|
+
this.markHealthyGeneration(key);
|
|
224
298
|
managed.refCount++;
|
|
225
299
|
managed.lastUsedAt = this.now();
|
|
226
300
|
return managed.client;
|
|
227
301
|
}
|
|
228
302
|
|
|
229
303
|
this.evictForAdmission();
|
|
304
|
+
this.ensureRespawnAllowed(key, root, server.id);
|
|
230
305
|
|
|
231
306
|
const client = this.clientFactory(root, server);
|
|
232
307
|
const initStartedAt = this.now();
|
|
@@ -253,7 +328,7 @@ export class LspManager {
|
|
|
253
328
|
if (this.clients.get(key) === newManaged) {
|
|
254
329
|
this.clients.delete(key);
|
|
255
330
|
}
|
|
256
|
-
await
|
|
331
|
+
await this.tombstoneStop(key, client);
|
|
257
332
|
throw err;
|
|
258
333
|
}
|
|
259
334
|
|
|
@@ -267,6 +342,16 @@ export class LspManager {
|
|
|
267
342
|
signal.throwIfAborted();
|
|
268
343
|
}
|
|
269
344
|
|
|
345
|
+
if (!client.isAlive()) {
|
|
346
|
+
this.recordDeadGeneration(key);
|
|
347
|
+
if (this.clients.get(key) === newManaged) {
|
|
348
|
+
this.clients.delete(key);
|
|
349
|
+
}
|
|
350
|
+
await this.tombstoneStop(key, client);
|
|
351
|
+
return this.getClient(root, server, signal);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.markHealthyGeneration(key);
|
|
270
355
|
newManaged.refCount++;
|
|
271
356
|
newManaged.lastUsedAt = this.now();
|
|
272
357
|
return client;
|
|
@@ -287,15 +372,20 @@ export class LspManager {
|
|
|
287
372
|
if (!managed) return;
|
|
288
373
|
if (client && managed.client !== client) return;
|
|
289
374
|
this.clients.delete(key);
|
|
290
|
-
void
|
|
375
|
+
void this.tombstoneStop(key, managed.client);
|
|
291
376
|
}
|
|
292
377
|
|
|
293
378
|
warmupClient(root: string, server: ResolvedServer): void {
|
|
294
379
|
if (this.disposed) return;
|
|
295
380
|
const key = this.getKey(root, server.id);
|
|
296
|
-
if (this.clients.has(key)) return;
|
|
381
|
+
if (this.clients.has(key) || this.pendingStops.has(key)) return;
|
|
297
382
|
|
|
298
383
|
this.evictForAdmission();
|
|
384
|
+
try {
|
|
385
|
+
this.ensureRespawnAllowed(key, root, server.id);
|
|
386
|
+
} catch {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
299
389
|
|
|
300
390
|
const client = this.clientFactory(root, server);
|
|
301
391
|
const initStartedAt = this.now();
|
|
@@ -326,7 +416,7 @@ export class LspManager {
|
|
|
326
416
|
if (this.clients.get(key) === managed) {
|
|
327
417
|
this.clients.delete(key);
|
|
328
418
|
}
|
|
329
|
-
void
|
|
419
|
+
void this.tombstoneStop(key, client);
|
|
330
420
|
},
|
|
331
421
|
);
|
|
332
422
|
}
|
|
@@ -379,8 +469,11 @@ export class LspManager {
|
|
|
379
469
|
for (const managed of this.clients.values()) {
|
|
380
470
|
stopPromises.push(stopClientBestEffort(managed.client));
|
|
381
471
|
}
|
|
472
|
+
const tombstonedStops = [...this.pendingStops.values()];
|
|
382
473
|
this.clients.clear();
|
|
383
|
-
|
|
474
|
+
this.respawnBudgets.clear();
|
|
475
|
+
await Promise.allSettled([...stopPromises, ...tombstonedStops]);
|
|
476
|
+
this.pendingStops.clear();
|
|
384
477
|
}
|
|
385
478
|
}
|
|
386
479
|
|
|
@@ -1 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
/** Repository marker: an ancestor containing this always wins over intermediate package markers. */
|
|
2
|
+
export const GIT_WORKSPACE_MARKER = ".git" as const;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Package/project markers. On their own they resolve to the NEAREST marked directory, but inside a
|
|
6
|
+
* request cwd they are only a fallback: a `.git` ancestor further up collapses nested package roots
|
|
7
|
+
* (one client per repository instead of one per monorepo package).
|
|
8
|
+
*/
|
|
9
|
+
export const PROJECT_WORKSPACE_MARKERS = [
|
|
10
|
+
"package.json",
|
|
11
|
+
"pyproject.toml",
|
|
12
|
+
"Cargo.toml",
|
|
13
|
+
"go.mod",
|
|
14
|
+
"pom.xml",
|
|
15
|
+
"build.gradle",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export const WORKSPACE_MARKERS = [GIT_WORKSPACE_MARKER, ...PROJECT_WORKSPACE_MARKERS] as const;
|
|
@@ -71,6 +71,9 @@ export async function runMcpStdioServer(
|
|
|
71
71
|
await runJsonRpcStdioServer({
|
|
72
72
|
input,
|
|
73
73
|
output,
|
|
74
|
+
// Idle stays disabled: hosts of this CLI (legacy opencode configs and
|
|
75
|
+
// standalone lsp-tools-mcp users) have no evidenced respawn for an exited
|
|
76
|
+
// stdio server; opencode in particular marks it failed without retry.
|
|
74
77
|
idleTimeoutMs: 0,
|
|
75
78
|
parentWatchdog: options.parentWatchdog ?? {},
|
|
76
79
|
handler: (request) => runWithRequestContext(requestContext, () => handleLspMcpRequest(request)),
|