Skip to content

Commit 718984c

Browse files
committed
Fix problems:
- handle progress correctly if total <= 0 - explicit 'streamed' status in run action result - use file lock on wm port file to avoid start of multiple WM servers at the same time - add missing WAL run id
1 parent 1f84571 commit 718984c

7 files changed

Lines changed: 77 additions & 15 deletions

File tree

docs/wm-er-protocol.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ The protocol is LSP-shaped with a small set of custom commands.
8888
"return_code": 0
8989
}
9090
```
91+
- Result (streamed): used when `partial_result_token` was provided and all
92+
results were delivered via `$/progress` notifications. Following LSP convention,
93+
the final response is an explicit completion signal — `result_by_format` is
94+
intentionally empty. The WM treats this as a valid completion; an empty
95+
`result_by_format` with any other status is a protocol error.
96+
```json
97+
{
98+
"status": "streamed",
99+
"result_by_format": "{}",
100+
"return_code": 0
101+
}
102+
```
91103
- Result (stopped):
92104
```json
93105
{
@@ -169,6 +181,9 @@ The protocol is LSP-shaped with a small set of custom commands.
169181
- Params: `{ "token": <token>, "value": "<stringified JSON partial result>" }`
170182
- The `token` must match `partial_result_token` from `actions/run`.
171183
- `value` is a JSON string produced by the ER from a partial run result.
184+
- When `$/progress` is used to deliver results, the final `actions/run` response
185+
must have `status: "streamed"` and empty `result_by_format`. See `actions/run`
186+
result (streamed) above.
172187

173188
## Error Handling and Cancellation
174189

finecode_extension_api/src/finecode_extension_api/code_action.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,12 @@ def __init__(
191191
self._completed = 0
192192

193193
async def __aenter__(self) -> ProgressContext:
194-
percentage = 0 if self._total is not None else None
194+
if self._total is None:
195+
percentage = None
196+
elif self._total <= 0:
197+
percentage = 100
198+
else:
199+
percentage = 0
195200
await self._sender.begin(
196201
self._title,
197202
percentage=percentage,
@@ -209,7 +214,10 @@ async def advance(self, steps: int = 1, message: str | None = None) -> None:
209214
self._completed += steps
210215
percentage = None
211216
if self._total is not None:
212-
percentage = min(int(self._completed / self._total * 100), 100)
217+
if self._total <= 0:
218+
percentage = 100
219+
else:
220+
percentage = min(int(self._completed / self._total * 100), 100)
213221
await self._sender.report(message=message, percentage=percentage)
214222

215223
async def report(

finecode_extension_runner/src/finecode_extension_runner/lsp_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,8 @@ async def run_action(
621621
# custom json encoder converts dict values and `convert_path_keys` is used to
622622
# convert dict keys
623623
result_by_format = response.to_dict()["result_by_format"]
624+
if not result_by_format and options_schema.partial_result_token is not None:
625+
status = "streamed"
624626
converted_result_by_format = {
625627
fmt: convert_path_keys(result) if isinstance(result, dict) else result
626628
for fmt, result in result_by_format.items()

src/finecode/wm_server/runner/runner_client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class ExtensionRunnerInfo(domain.ExtensionRunner):
6363
class RunActionResponse:
6464
result_by_format: dict[str, RunActionRawResult]
6565
return_code: int
66+
status: str = "success"
6667

6768
def json(self) -> dict[str, Any]:
6869
result = self.result_by_format.get("json")
@@ -142,10 +143,12 @@ async def run_action(
142143
except json.JSONDecodeError as exception:
143144
raise ActionRunFailed(f"Failed to decode result json: {exception}") from exception
144145

145-
if command_result["status"] == "stopped":
146+
status = command_result["status"]
147+
148+
if status == "stopped":
146149
raise ActionRunStopped(message=result_by_format)
147150

148-
return RunActionResponse(result_by_format=result_by_format, return_code=return_code)
151+
return RunActionResponse(result_by_format=result_by_format, return_code=return_code, status=status)
149152

150153

151154
async def merge_results(

src/finecode/wm_server/services/partial_results_service.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,15 @@ async def _forward_progress() -> None:
252252

253253
# Responses collected by the context manager from runner tasks
254254
for resp in ctx.responses:
255+
if resp.status == "streamed":
256+
return_codes.append(resp.return_code)
257+
continue
258+
if not resp.result_by_format:
259+
raise runner_client.ActionRunFailed(
260+
f"ER returned empty result with status '{resp.status}' for project={project.name}; "
261+
"expected 'streamed' status when result_by_format is empty"
262+
)
263+
255264
json_result = resp.json()
256265
logger.trace(f"partial_results: final result for project={project.name}: return_code={resp.return_code}, keys={list(json_result.keys()) if isinstance(json_result, dict) else type(json_result)}")
257266
final_results.append(json_result)

src/finecode/wm_server/services/run_service/proxy_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,13 @@ async def run_action_and_notify(
174174
runner: runner_client.ExtensionRunnerInfo,
175175
run_trigger: runner_client.RunActionTrigger,
176176
dev_env: runner_client.DevEnv,
177+
wal_run_id: str,
177178
result_formats: list[runner_client.RunResultFormat] | None = None,
178179
progress_token: int | str | None = None,
179180
) -> runner_client.RunActionResponse:
180181
options: dict[str, typing.Any] = {
181182
"partial_result_token": partial_result_token,
183+
"wal_run_id": wal_run_id,
182184
"meta": {"trigger": run_trigger.value, "dev_env": dev_env.value},
183185
}
184186
if progress_token is not None:
@@ -265,6 +267,7 @@ async def run_with_partial_results(
265267
progress_token: int | str | None = None,
266268
) -> collections.abc.AsyncIterator[RunWithPartialResultsContext]:
267269
logger.trace(f"Run {action_name} in project {project_dir_path}")
270+
wal_run_id = wal.new_wal_run_id()
268271

269272
result: AsyncList[domain.PartialResultRawValue] = AsyncList()
270273
progress_result: AsyncList[domain.ProgressRawValue] | None = None
@@ -321,6 +324,7 @@ async def run_with_partial_results(
321324
runner=runner,
322325
run_trigger=run_trigger,
323326
dev_env=dev_env,
327+
wal_run_id=wal_run_id,
324328
result_formats=result_formats,
325329
progress_token=progress_token,
326330
)

src/finecode/wm_server/wm_lifecycle.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@
1313
import subprocess
1414
import sys
1515
import tempfile
16+
import time
1617

18+
from filelock import FileLock
1719
from loguru import logger
1820

1921
NO_CLIENT_TIMEOUT_SECONDS = 30
22+
STARTUP_LOCK_FILENAME = "wm_start.lock"
23+
STARTUP_READY_TIMEOUT_SECONDS = 10.0
24+
STARTUP_READY_POLL_INTERVAL_SECONDS = 0.1
2025

2126

2227
def _cache_dir() -> pathlib.Path:
@@ -62,19 +67,35 @@ def is_running() -> bool:
6267
return running_port() is not None
6368

6469

70+
def _startup_lock() -> FileLock:
71+
"""Serialize shared WM startup across processes."""
72+
lock_path = _cache_dir() / STARTUP_LOCK_FILENAME
73+
lock_path.parent.mkdir(parents=True, exist_ok=True)
74+
return FileLock(str(lock_path))
75+
76+
6577
def ensure_running(workdir: pathlib.Path, log_level: str = "INFO") -> None:
6678
"""Start the WM server as a subprocess if not already running."""
67-
if is_running():
68-
return
69-
70-
python_cmd = sys.executable
71-
logger.info(f"Starting FineCode WM server subprocess in {workdir}")
72-
subprocess.Popen(
73-
[python_cmd, "-m", "finecode", "start-wm-server", f"--log-level={log_level}"],
74-
cwd=str(workdir),
75-
stdout=subprocess.DEVNULL,
76-
stderr=subprocess.DEVNULL,
77-
)
79+
with _startup_lock():
80+
if is_running():
81+
return
82+
83+
python_cmd = sys.executable
84+
logger.info(f"Starting FineCode WM server subprocess in {workdir}")
85+
subprocess.Popen(
86+
[python_cmd, "-m", "finecode", "start-wm-server", f"--log-level={log_level}"],
87+
cwd=str(workdir),
88+
stdout=subprocess.DEVNULL,
89+
stderr=subprocess.DEVNULL,
90+
)
91+
92+
# Keep the lock until the spawned server is observable via discovery and
93+
# TCP probe so competing callers do not start a duplicate process.
94+
deadline = time.monotonic() + STARTUP_READY_TIMEOUT_SECONDS
95+
while time.monotonic() < deadline:
96+
if is_running():
97+
return
98+
time.sleep(STARTUP_READY_POLL_INTERVAL_SECONDS)
7899

79100

80101
async def wait_until_ready(timeout: float = 30) -> int:

0 commit comments

Comments
 (0)