Skip to content

Commit 163acdc

Browse files
committed
Stop capping workspace fan-out width, narrow inspect_code bridge dispatches (ADR-0095)
A 72-project workspace's nested lint from inspect_code was refused outright: OrchestrationPolicy.max_project_fanout (64) fired on any fan-out wider than the cap once orchestration_depth > 0, even though the width was just the workspace's own project count, not a runaway. A width cap can never distinguish the two, since a nested fan-out's width is bounded by the same project count a depth-0 call would use. Drop it and rely solely on max_recursion_depth, extended to the ER back-channel (`run_action_in_workspace`) so every fan-out entry point enforces the same height guard. The lint/type_check inspect_code bridges were also the source of the runaway width in the first place: each per-project dispatch sent the nested action the workspace-wide payload.project_paths instead of its own project, so the nested instance re-resolved the whole workspace and gathered across it again — N instances x N projects. Narrow the dispatched payload to the one project each bridge call owns. Narrowing exposed a second bug in the lint/type_check handlers' IDE opened-files branch: a narrowed run reported every open file across all projects, not just its own, so a foreign project's open file was sent back with no entry in this run's partial result — an empty list the IDE reads as "no diagnostics," clearing that file's real ones. Filter opened files down to the run's own projects before reporting them.
1 parent 51282d7 commit 163acdc

15 files changed

Lines changed: 947 additions & 76 deletions

docs/guides/wm-server-internals.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,10 +437,12 @@ the env deleted and recreated. A timed-out check is also retried with a longer d
437437
(`VERSION_CHECK_TIMEOUTS_SEC`) before the env is declared invalid, and every invalid verdict
438438
carries its reason into the `prepare-envs` warning.
439439

440-
`OrchestrationPolicy.max_project_fanout` (64) is a *separate* mechanism and is not a capacity
441-
limit: it refuses, and it applies only when `orchestration_depth > 0`. It guards against runaway
442-
recursive orchestration, not against a large workspace. A request arriving from a person at depth
443-
0 is never refused for width — see ADR-0067, which amends ADR-0016 on this point.
440+
`OrchestrationPolicy.max_recursion_depth` bounds orchestration at every entry point: the
441+
project executor, and both workspace fan-out entries (`WorkspaceExecutor` and the streaming
442+
batch handler). Each refuses a call whose caller is already at the limit. There is **no width
443+
refusal**: a nested fan-out's width can never exceed the workspace's project count, so a width
444+
cap would measure the workspace rather than a runaway. Subprocess width is bounded at the leaf
445+
by the process budget above — see ADR-0095, which supersedes ADR-0067's width decision.
444446

445447
### Auto-repair (`install_env_for_project`)
446448

presets/fine_lint/fine_lint/lint_handler.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,17 @@ async def run(
128128
and run_meta.dev_env == code_action.DevEnv.IDE
129129
and run_meta.trigger == code_action.RunActionTrigger.SYSTEM
130130
):
131+
# Only this run's projects: a narrowed run (e.g. one project of an
132+
# inspect_code bridge) must not report the other projects' open files,
133+
# because it would send them as empty -- and an empty list clears that
134+
# file's diagnostics in the IDE, racing the project that owns it.
135+
opened_by_project = group_files_by_project(
136+
self.file_editor.get_opened_files(), project_paths
137+
)
131138
file_uris = [
132-
path_to_resource_uri(p) for p in self.file_editor.get_opened_files()
139+
path_to_resource_uri(p)
140+
for files in opened_by_project.values()
141+
for p in files
133142
]
134143
else:
135144
files = await _list_workspace_files(

presets/fine_lint/fine_lint/lint_inspect_code_bridge_handler.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ async def _run_lint_for_project(
6565
payload=LintRunPayload(
6666
target=LintTarget(payload.target.value),
6767
file_paths=payload.file_paths,
68-
project_paths=payload.project_paths,
68+
# `lint` is workspace-scoped, and this dispatches it into one
69+
# project. Without narrowing, every per-project instance
70+
# re-resolves the whole workspace and gathers across it: N
71+
# instances x N projects.
72+
project_paths=[path_to_resource_uri(project_path)],
6973
),
7074
meta=run_meta,
7175
project_paths=[project_path],
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""The IDE opened-files branch covers only the run's own projects.
2+
3+
A narrowed run (one project of an ``inspect_code`` bridge) must not report the
4+
other projects' open files. An opened file with no entry in a project's partial
5+
result is sent as an empty list, and the IDE clears that file's diagnostics —
6+
so a foreign file in a narrowed run would blank a buffer another project owns.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pathlib
12+
import typing
13+
14+
from fine_lint.lint_action import LintRunPayload, LintTarget
15+
from fine_lint.lint_files_action import (
16+
LintFilesAction,
17+
LintFilesRunResult,
18+
)
19+
from fine_lint.lint_handler import LintHandler, LintHandlerConfig
20+
from fine_src_artifacts.list_src_artifact_files_by_lang_action import (
21+
ListSrcArtifactFilesByLangAction,
22+
)
23+
from finecode_extension_api.interfaces.iworkspaceinfoprovider import (
24+
ProjectConfigStatus,
25+
WorkspaceProject,
26+
)
27+
from finecode_extension_api.resource_uri import path_to_resource_uri
28+
29+
from finecode_extension_api import code_action
30+
31+
32+
class _FakeWorkspaceInfoProvider:
33+
def __init__(self, project_paths: list[pathlib.Path]) -> None:
34+
self._project_paths = project_paths
35+
36+
async def get_workspace_projects(self) -> list[WorkspaceProject]:
37+
return [
38+
WorkspaceProject(path=path, config_status=ProjectConfigStatus.VALID)
39+
for path in self._project_paths
40+
]
41+
42+
43+
class _FakeFileEditor:
44+
def __init__(self, opened_files: list[pathlib.Path]) -> None:
45+
self._opened_files = opened_files
46+
47+
def get_opened_files(self) -> list[pathlib.Path]:
48+
return list(self._opened_files)
49+
50+
51+
class _FakeLogger:
52+
def info(self, message: str) -> None: ...
53+
54+
def debug(self, message: str) -> None: ...
55+
56+
def warning(self, message: str) -> None: ...
57+
58+
def error(self, message: str) -> None: ...
59+
60+
61+
class _RecordedCall:
62+
def __init__(
63+
self,
64+
action_type: type,
65+
payload: code_action.RunActionPayload,
66+
project_paths: list[pathlib.Path],
67+
) -> None:
68+
self.action_type = action_type
69+
self.payload = payload
70+
self.project_paths = project_paths
71+
72+
73+
class _RecordingWorkspaceActionRunner:
74+
def __init__(self) -> None:
75+
self.calls: list[_RecordedCall] = []
76+
77+
async def run_action_in_projects(
78+
self,
79+
action_type: type,
80+
payload: code_action.RunActionPayload,
81+
meta: code_action.RunActionMeta,
82+
project_paths: list[pathlib.Path] | None = None,
83+
concurrently: bool = True,
84+
) -> dict[pathlib.Path, code_action.RunActionResult]:
85+
assert project_paths is not None
86+
self.calls.append(_RecordedCall(action_type, payload, list(project_paths)))
87+
if action_type is LintFilesAction:
88+
return {
89+
path: LintFilesRunResult(
90+
messages={uri: [] for uri in payload.file_paths}
91+
)
92+
for path in project_paths
93+
}
94+
return {}
95+
96+
97+
class _CollectingPartialResultSender:
98+
def __init__(self) -> None:
99+
self.results: list[code_action.RunActionResult] = []
100+
101+
async def send(self, result: code_action.RunActionResult) -> None:
102+
self.results.append(result)
103+
104+
105+
class _FakeProgress:
106+
async def __aenter__(self) -> typing.Self:
107+
return self
108+
109+
async def __aexit__(self, *exc: object) -> bool:
110+
return False
111+
112+
async def advance(self, steps: int, message: str | None = None) -> None: ...
113+
114+
115+
class _FakeRunContext:
116+
def __init__(
117+
self,
118+
sender: _CollectingPartialResultSender,
119+
trigger: code_action.RunActionTrigger,
120+
dev_env: code_action.DevEnv,
121+
) -> None:
122+
self.meta = code_action.RunActionMeta(trigger=trigger, dev_env=dev_env)
123+
self.partial_result_sender = sender
124+
125+
def progress(
126+
self, title: str, *, total: int | None = None, cancellable: bool = False
127+
) -> _FakeProgress:
128+
return _FakeProgress()
129+
130+
131+
def _handler(
132+
action_runner: _RecordingWorkspaceActionRunner,
133+
project_paths: list[pathlib.Path],
134+
opened_files: list[pathlib.Path],
135+
) -> LintHandler:
136+
return LintHandler(
137+
config=LintHandlerConfig(),
138+
workspace_action_runner=action_runner, # type: ignore[arg-type]
139+
workspace_info_provider=_FakeWorkspaceInfoProvider(project_paths),
140+
file_editor=_FakeFileEditor(opened_files), # type: ignore[arg-type]
141+
logger=_FakeLogger(), # type: ignore[arg-type]
142+
)
143+
144+
145+
async def test_narrowed_ide_run_lints_only_its_own_opened_files(
146+
tmp_path: pathlib.Path,
147+
) -> None:
148+
"""With the run narrowed to project A, only A's open files are linted and
149+
the other project's open file is never sent as an empty result."""
150+
project_a = tmp_path / "a"
151+
project_b = tmp_path / "b"
152+
file_a = project_a / "mod.py"
153+
file_b = project_b / "mod.py"
154+
action_runner = _RecordingWorkspaceActionRunner()
155+
sender = _CollectingPartialResultSender()
156+
157+
await _handler(
158+
action_runner, [project_a, project_b], [file_a, file_b]
159+
).run(
160+
LintRunPayload(
161+
target=LintTarget.PROJECT,
162+
project_paths=[path_to_resource_uri(project_a)],
163+
),
164+
_FakeRunContext(
165+
sender, code_action.RunActionTrigger.SYSTEM, code_action.DevEnv.IDE
166+
), # type: ignore[arg-type]
167+
)
168+
169+
assert len(action_runner.calls) == 1
170+
call = action_runner.calls[0]
171+
assert call.action_type == LintFilesAction
172+
assert call.project_paths == [project_a]
173+
assert call.payload.file_paths == [path_to_resource_uri(file_a)]
174+
assert ListSrcArtifactFilesByLangAction not in [
175+
recorded.action_type for recorded in action_runner.calls
176+
]
177+
sent_uris = {uri for result in sender.results for uri in result.messages}
178+
assert path_to_resource_uri(file_b) not in sent_uris
179+
180+
181+
async def test_unscoped_ide_run_lints_opened_files_in_every_project(
182+
tmp_path: pathlib.Path,
183+
) -> None:
184+
"""Without a project narrowing the IDE path still lints every project's
185+
open files — the filter must not shrink the common direct-lint case."""
186+
project_a = tmp_path / "a"
187+
project_b = tmp_path / "b"
188+
file_a = project_a / "mod.py"
189+
file_b = project_b / "mod.py"
190+
action_runner = _RecordingWorkspaceActionRunner()
191+
sender = _CollectingPartialResultSender()
192+
193+
await _handler(
194+
action_runner, [project_a, project_b], [file_a, file_b]
195+
).run(
196+
LintRunPayload(target=LintTarget.PROJECT, project_paths=None),
197+
_FakeRunContext(
198+
sender, code_action.RunActionTrigger.SYSTEM, code_action.DevEnv.IDE
199+
), # type: ignore[arg-type]
200+
)
201+
202+
lint_calls = [
203+
call for call in action_runner.calls if call.action_type == LintFilesAction
204+
]
205+
linted_uris = {uri for call in lint_calls for uri in call.payload.file_paths}
206+
assert linted_uris == {
207+
path_to_resource_uri(file_a),
208+
path_to_resource_uri(file_b),
209+
}

0 commit comments

Comments
 (0)