Skip to content

Navigation Menu

Sign in
Sign up

Improve concurrency handling, fix part of CI pipeline and more - #39

Open
Aksem wants to merge 45 commits into
main from
fix/lsp-request-concurrency-cap
Open

Improve concurrency handling, fix part of CI pipeline and more #39
Aksem wants to merge 45 commits into
main from
fix/lsp-request-concurrency-cap

Conversation

@Aksem

@Aksem Aksem commented Sep 5, 2026

Copy link
Copy Markdown
Member

No description provided.

Aksem added 30 commits July 21, 2026 06:56
...tartup fan-out (ADR-0063)
Three related gaps in how the WM manages Extension Runner processes:
1. A start attempt that spawns the OS process but then fails or times
 out before the port handshake/RPC connection completes (debugger
 port wait, connect_to_server) left that process orphaned — nothing
 ever revisited a runner once it was marked FAILED. JsonRpcClient
 gains force_kill() (SIGKILL via os.killpg, since the process is
 started with start_new_session=True so it and anything it spawned,
 e.g. a package-manager invocation, can be reached as one group);
 every failed-start path in _start_extension_runner_process now
 calls it. client.pid is now captured as soon as the process is
 spawned (before the handshake), so force_kill has a target even
 when start() never returns successfully.
2. shutdown_service.on_shutdown stopped RUNNING/REPAIRING runners one
 at a time via the old *_sync helpers, so total shutdown time scaled
 with runner coun
 bounded to _STOP_TIMEOUT_SEC, but the total no longer multiplies.
 A runner still INITIALIZING at shutdown was never sent a graceful
 exit (that only happens once RUNNING) and has no way to learn the
 WM is going away, so those are force-killed directly instead.
 The now-unused stop_extension_runner_sync/shutdown_sync/exit_sync
 helpers are removed. Deliberately no force-kill fallback was added
 to stop_extension_runner's own timeout path: an ER that already
 received exit may still be tearing down its own spawned
 subprocesses, and killing it mid-cleanup risks orphaning exactly
 the children a slower-but-graceful exit would have reaped itself.
3. Nothing bounded how many ERs could be starting at once — process
 spawn + interpreter init + imports is CPU/memory-bursty, and every
 trigger (workspace init, a matrixed run, prepare-envs' runner-start
 step) funnels through the same _start_extension_runner_process
 chokepoint. It's now held under ws_context.er_startup_semaphore
 (sized via FINECODE_WM_MAX_CONCURRENT_ER_STARTS or the shared
 machine-budget formula, ADR-0063) from just before spawn until the
 RPC channel connects — not around whatever the triggering action
 does afterward in the ER's own process, a separate and far more
 variable cost this cap deliberately leaves unconstrained.
Also switches the `@typing.override` decorator to a version-gated
`typing`/`typing_extensions` import in the two lowest-level protocol
files touched here (finecode_jsonrpc/client.py,
wm_server/runner/_internal_client_types.py), since `typing.override`
requires 3.12+ and these run under the workspace's >=3.11 floor;
pulls in typing-extensions as an explicit dependency where needed.
...ncing (PRD-0004-AC6/AC7)
An otlp_endpoint that's merely malformed previously surfaced as a
confusing failure deep inside OTel SDK setup. telemetry._validate_endpoint
now parses host+port up front (with or without a scheme) and raises a
clear, actionable error at the three init_* call sites (logging, tracer,
meter provider) before touching the SDK.
A configured-but-unreachable collector — the common case when FineCode
starts before `scripts/observability.sh up`, or before a Compose otel
profile comes up alongside the container — no longer needs to be reachable
at startup: exporters already buffer and retry, so this doesn't change
behavior, but a stream of gRPC export-retry warnings for the process
lifetime is now silenced (opentelemetry.exporter logger raised to ERROR)
in favor of a single one-time reachability heads-up per endpoint, logged
once and cached in _probed_endpoints so it can't repeat.
Documents the resulting contract in configuration.md: malformed values
fail fast, unreachable ones don't block startup, and points to the
Observability guide for running a backend locally.
Ships the core release-automation loop: release_workspace_packages
(fine_release) discovers every workspace package whose declared version
is absent from its registry, computes a dependency-respecting publish
order via fine_dep_graph, and sweeps them in order. A package whose
transitive same-run dependency failed is BLOCKED rather than attempted
(AC7, AC11); a successfully published package gets a best-effort git
tag recording the publish (ADR-0060), via a new minimal fine_git preset
(create_git_tag, push_git_refs) that fine_release depends on without
pulling in release semantics.
Getting there required fixing a registry-endpoint bug the old flow
never exercised: Repository carried a single `url`, but PyPI splits
reads and writes across two hosts (pypi.org vs upload.pypi.org). Reusing
one URL for both silently misroutes — an index lookup against the
upload host 404s, which is indistinguishable from "nothing published"
and would make a package look unpublished when it isn't. Repository
now carries index_url/upload_url explicitly (matching pip's/twine's own
naming), and fine_python_package_info.registry_endpoints validates each
is used for its own role before any request goes out.
is_artifact_published_to_registry is replaced by list_published_artifacts,
which reports the registry's actual filenames for a version instead of a
bool per caller-supplied dist path. This lets a caller (the release
sweep, a dry-run preview) ask "what's published" before it has built
anything locally, and derive what still needs uploading by filename
membership rather than needing to already know the dist paths up front.
publish_artifact_to_registry and publish_artifact now report registry
failures in their results (`error`/`failed_registries`) instead of
raising, and publish_artifact_handler dispatches to registries with
asyncio.gather instead of a TaskGroup — one registry's failure no
longer cancels uploads already in flight to the others (ADR-0062).
publish_and_verify_artifact's result gains a matching `publish_errors`
map alongside its existing `verification_errors`.
get_src_artifact_version's src_artifact_def_path is now optional,
defaulting to the current project, so release handlers fanning the
same payload out to every candidate project don't each need to resolve
their own path first.
Adds finecode_extension_runner/testing: an in-memory handler test
harness (run_handler/handler_test_session) that boots a real ER
in-process against stub services (IFileEditor, ICommandRunner, WAL
writer, progress/partial-result senders) instead of stubbing each
handler's dependencies by hand per test. Used throughout the new
fine_git and fine_python_package_info tests, and covers its own
progress-collection behavior.
CI workflow env vars are updated to the new index_url/upload_url shape
for both TestPyPI and PyPI publish steps; also drops a stale
"test with all supported python versions" TODO that predates the
interpreter-matrix work already handling that.
... (ADR-0067)
Two independent gaps closed together:
Converting an env to an interpreter matrix (ADR-0047) silently orphans
its old venv: config only sees the expanded children afterward, so
neither prepare-envs nor --recreate ever revisits the base name's
.venvs/ directory again. Same fate for a renamed env or a preset that
stops contributing one — the venv just sits there, easily hundreds of
MB per project. list_envs reports declared/on-disk/orphaned status per
env from the filesystem alone (cheap across a whole workspace, no
interpreter execution); remove_envs deletes them, defaulting to the
orphaned set. Two guards apply to whatever was asked for before
existence is even checked, so a rejection can't depend on disk state:
a declared env needs force (an ER may be running in it), and the
current env is never removable regardless. IFileManager.remove_dir
gains tolerant=True for this: read-only contents, dangling symlinks,
and already-absent paths are all treated as "make it gone" rather than
errors, since a half-created or permission-damaged venv is exactly
what removal should clear.
Separately, `run` fan-out across projects had no concurrency bound,
unlike prepare-envs (ADR-0055) — a workspace-wide run put every
project's ER to work at once, each free to spawn subprocesses up to
its own ICommandRunner cap, so the two layers compose multiplicatively
exactly as ADR-0055 warned about. max_project_fanout couldn't be
reused directly to fix this: it's a runaway-recursion guard that
refuses, meant for nested orchestration (ADR-0016), and applying it at
depth 0 would make every workspace-wide action unusable past an
arbitrary workspace size someone chose on purpose. Depth 0 now gets a
per-call semaphore instead (FINECODE_WM_RUN_MAX_CONCURRENT_PROJECTS,
same sqrt-split default formula as ADR-0055) that throttles without
ever refusing; the recursion guard still applies, and only applies,
once orchestration_depth > 0. The semaphore is built fresh per fan-out
call rather than shared, since a shared one would let an outer fan-out
hold every permit while waiting on an inner one that can never
acquire any.
fine_release's release_workspace_packages action previously owned both
the cross-package concerns (candidate discovery, dependency order,
blocking, tagging, pushing) and the single-package concerns (build,
publish, verify) in one handler chain, so a package could not
customize its own release steps without touching the workspace-wide
action. ReleasePackageAction is now a package's own chain
(build/publish/record_tag), and release_workspace_packages delegates
to it once per candidate via run_action_in_projects, keeping only what
is inherently cross-package: ordering, dependent-blocking, and
publishing every git ref the run produced in a single push (fine_git's
push_git_refs) rather than once per package.
Extends ADR-0054's canonical-source resolution (previously actions
only) to handlers: resolve_action_meta now reports canonicalSource per
handler alongside fileLoc, since config almost always declares a
handler via its package's __init__.py re-export rather than the
module the class is actually defined in. ActionHandler gains a
canonical_source field (None until the hosting ER resolves it, or
permanently if the class can't be imported), and
parse_workspace_actions carries it through from the wire payload.
finecode_extension_runner/testing's handler_test_session needed a way
to observe progress calls directly instead of only through the
token/global-function forwarding meant for production RPC, so
run_action now accepts an explicit progress_sender that bypasses that
path. Also fixes a bug the new fine_release tests exposed: handler
config structuring only caught cattrs.ClassValidationError, so a
malformed entry in a list- or dict-typed config field raised the
sibling IterableValidationError uncaught instead of surfacing as a
readable ActionFailedException.
create_envs now reports whether it actually built a virtualenv or
found a valid one already there (CreateEnvsRunResult.created), and
create/install env progress messages use a new env_label() helper
(<project>/<env>) instead of the bare env name, which was ambiguous
whenever one dispatch call spans multiple same-named envs across
projects (e.g. prepare-envs' dev_workspace bootstrap step).
remove_dir's --recreate path now passes tolerant=True, since a
half-created or permission-damaged venv being removed before
recreation is exactly the case tolerant removal exists for.
Clarifies IProjectInfoProvider.get_project_raw_config's docstring: the
config it serves is resolved (presets merged, interpreter matrices
already expanded per ADR-0047), not the file's own raw content, which
was easy to misread as unprocessed. Adds a regression test pinning
that the expansion happens before the config is stored for serving,
not just that resolve_interpreter_matrices itself produces the right
shape, plus a comment in prepare_envs_service.py on why step 3 keeps
installing into dev_workspace even though step 5 excludes it.
...070)
A service declaration's identity is its `interface` dotted path, but that
can't be written readably in an environment variable (dots collapse to
underscores and collide with the `__` nesting separator already used for
handler config). FINECODE_SERVICE_CONFIG_<NAME>__<PARAM_PATH> now
addresses a service by a short alias instead: derived from the
interface's class name (leading `I` + uppercase stripped, snake-cased),
never declared, so a declaration and an environment can never disagree
about it. ServiceConfigResolver (finecode_extension_runner/service_config.py)
merges raw_config (activator) < declared config < env override by deep
merge, and reports ambiguous aliases (two interfaces deriving the same
name) or unmatched override names once all bindings have registered.
Resolution happens in the Extension Runner, not the WM, because only the
ER can see activator-registered bindings alongside declared ones.
IServiceRegistry.register_impl drops its `singleton` flag: every binding
is already cached for the runner's life, and the flag never controlled
that -- it only masked the case where a `[[tool.finecode.service]]` entry
had no way to pass it, silently handing a concrete-injecting handler a
second instance. register_impl now always alias-binds the concrete type
to the interface's instance.
Config-only `[[tool.finecode.service]]` entries (no `source`) are now
legal: they carry config for a binding an activator already owns instead
of restating and pinning its implementation. IRepositoryCredentialsProvider
loses its push-seed methods (set_credentials/add_repository) since they
only existed to fake provisioning that belongs to the concrete impl
(ADR-0068); ConfigRepositoryCredentialsProvider is provisioned entirely
through service config now, and the old
PublishAndVerifyArtifactInitRepositoryProviderHandler -- an init action
that only carried config -- is deleted per S-203. The one remaining
init_repository_provider_handler is documented as dynamic-runtime-seeding
only, injecting the concrete provider type rather than the interface.
Adds docs/guides/designing-services.md: the S-1xx (contract) / S-2xx
(provisioning) / S-3xx (registration/lifecycle) rule set this change
implements, distilled from ADR-0038/0056/0068/0070. docs/configuration.md
documents the env-var format and service-name derivation;
docs/reference/services.md, docs/wm-er-protocol.md and
docs/guides/designing-actions-rules.md are updated to match.
HandlerInfo gains a `canonical_source` field (module a handler class is
actually defined in, vs. the config-facing `source` alias), following the
same canonical-source resolution already used for actions.
Also:
- Registers IKnowledgeStore (finecode_extension_api) with an ER-side
 KnowledgeStoreImpl, and wires runner_manager/wm_server to route two
 knowledge methods to it.
- Adds typing-extensions as an explicit dependency for fine_python_pyrefly,
 fine_python_ruff, fine_toml_tombi and finecode_extension_runner, since
 their `override` compat shim for Python <3.12 depends on it directly
 rather than transitively.
- Adds the fine_python_envs preset (derives an env's interpreter axis from
 requires-python via fine_envs' sync_toolchains/check_toolchains
 contracts) and a fine_git README.
- RuffFormatFileHandler now returns an empty `code` when the file did not
 change, instead of the unchanged content.
check_toolchains (ADR-0053) only surfaced axis drift through its own
run result; nothing routed it into audit_code, the umbrella CI/editor
diagnostics run through. CheckToolchainsAuditCodeBridgeHandler adds
that path: one ERROR diagnostic per stale env, anchored at the
project's definition file (position 0,0, matching how import-linter
anchors whole-project violations) since the drift belongs to the file,
not a line in it. CheckToolchainsRunResult gains project_def_path so
the bridge has something to key diagnostics on; CheckToolchainsHandler
fills it from IProjectInfoProvider since it runs in the project's own
ER and is the only place that knows the path without a second lookup.
The dependency on fine_audit_code/fine_inspect_code stays optional
(fine_envs[audit]) since fine_envs is the mandatory base preset every
project loads and only this one module needs the audit umbrella.
While building the bridge's tests, found the same bug already live in
check_toolchains_precommit_bridge_handler: it merged every project's
CheckToolchainsRunResult with update(), but that merge is scoped
within one project (R-302) and EnvToolchainAxis is keyed by env name,
unique inside a project but not across them. Two projects sharing the
`testing` env name (every matrix project does) collapsed into one
entry showing only the first project's versions, silently hiding the
second project's drift. Cross-project aggregation now happens in the
bridge itself, keeping one action_results entry per drifted project
under its own label.
Also:
- fine_python_test's run_tests moves from the single-interpreter `dev`
 env to a new `testing` matrix env whose interpreter axis is derived
 from requires-python via fine_python_envs' sync_python_interpreters
 (ADR-0053/0057), instead of running tests against only `dev`'s
 interpreter.
- Adds regression tests for _collect_services_in_config covering
 config-only entries (source/env omitted) and same-process alias
 collisions, which ADR-0070 left unmatched by test coverage.
read_file previously took a block=True flag shared with the same
_blocked_files map that a modifying caller used, so a read nested
anywhere inside an in-flight modification (e.g. formatting
pyproject.toml triggers a language-detection lookup that reads
pyproject.toml through a second session) waited on a release only its
own caller could perform, deadlocking the ER. Reads are now always
non-blocking and modification is its own operation, modify_file,
which claims exclusion on the file path (not the session) for as long
as the claim is held. Session identity is kept only for teardown:
ending a session releases whatever claims it still holds.
save_file gains an optional if_version to make a write conditional on
the version it was based on: if the file changed underneath the
caller, FileVersionConflict is raised and nothing is written, rather
than silently discarding whatever produced the newer version.
SaveFormatFileHandler uses this so a formatting pipeline whose input
went stale mid-run refuses the write and logs, instead of last-writer-
wins clobbering a concurrent edit.
InMemoryFileEditor's test double derives versions from content hash
instead of a constant "1", so if_version checks in tests mean
something, and gains matching modify_file locking.
LSP leaves parallel request execution to the server's discretion, so a client
that pipelines without bound relies on unspecified behaviour. tombi guards its
document, reference and schema stores with locks it reports contention on, and
with many documents in flight on one session individual requests stall past any
usable timeout while the process sits idle rather than busy.
Add an opt-in max_concurrent_requests to LspService, defaulting to unbounded so
pyrefly and ruff-lsp are unaffected, and set it to 1 for tombi. The limit bounds
whole interactions rather than single messages: the document synchronization
around a request mutates the same server state the request reads, so letting
another document's notifications interleave defeats the point. Forwarded file
events take the limit too, before the per-uri lock — the reverse order
deadlocks against format_file, which already holds them that way round.
Measured over 194 workspace TOML files against one tombi process at a 30s
timeout: unbounded fails 88 of 194 requests; every bounded value from 1 to 8
completes cleanly in 41-51s.
Alongside it:
- Drop didOpen for a change to a document the server does not hold open.
 didClose is only sent for documents an editor session opened, so nothing
 would ever close it again and every file written through the editor
 accumulated in the server for the lifetime of the session.
- Send didClose even when a format request fails, for the same reason.
- Answer workspace/inlayHint/refresh, which servers send regardless of the
 declared client capability — for some, once per document sync.
- Run tombi with --offline. No action here consumes live dependency data, so
 its per-dependency network lookups are latency for results nothing reads.
Claude-Session: https://claude.ai/code/session_019ZMjy2zw1jTjkfXCWNfEe6 
Whitespace/line-wrap/quote-style normalization from running the
project formatter on every file with no pending unrelated changes.
Files that already had unstaged edits before formatting are excluded
from this commit so their formatting and content changes aren't
mixed together.
format and check_formatting disagreed persistently: format reported a
set of files as reformatted, check_formatting run straight afterwards
reported the same files as needing formatting, and neither run changed
what was on disk. Since check_formatting is format with save=False,
that can only mean the pipeline was not deterministic across runs.
IsortFormatFileHandler built isort.settings.Config without anchoring it
to the project, so isort inferred which packages are first-party from
the cwd of the process-executor worker -- a value the handler does not
control. With the project directory current, a project's own package is
first-party and its imports get their own trailing section; with any
other directory current, nothing is first-party and the same imports
collapse into the third-party block. Consecutive runs therefore
produced two different layouts for the same file and each run reported
the other one's output as needing formatting.
settings_path alone does not fix this: isort derives src_paths, which
is what first-party placement actually consults, from `directory`,
which independently defaults to os.getcwd(). Both are now set to the
project directory, so the same file sorts identically regardless of
where the runner was started.
The cwd was unstable because JsonRpcClient.start established it by
mutating the workspace manager's own cwd around the spawn and restoring
it afterwards. Runners start up to er_startup_semaphore at a time (7 on
this machine) and the spawn itself is dispatched to the io thread, so
an ER could be forked while another client held the cwd -- inheriting
that project's directory, or the restored original. The VIRTUAL_ENV
pop/restore had the same shape. Both are now passed to
create_subprocess_shell explicitly, which is what StdioTransport
already did.
Verified with a whole-workspace format followed immediately by
check_formatting: 43 files reformatted, then 0 files needing
formatting.
The loguru import move in client.py is that fix applied to this file --
it was one of the files whose import layout had been flipping.
Second pass of formatter normalization (import ordering, line
wrapping) over files not covered by the previous formatting commit.
Files with pre-existing unstaged edits are still excluded so their
formatting and content changes aren't mixed together.
The wm-layered import-linter contract was recently repaired to point at
real modules (it previously referenced ones that didn't exist, so it
never ran). Running it for the first time surfaced 7 direction
violations against the documented layer stack (docs/guides/wm-server-
internals.md). All are implementation debt, not a contract bug — fixed
in four groups:
- domain.py imported ErLoggingConfig from config.config_models (a
 stranded pure-data type). Moved it into domain.py; config_models.py
 and runner_client.py now import it from there.
- context.py importing runner.runner_client for ExtensionRunnerInfo
 can't be fixed the same way — that type wraps a live JsonRpcClient,
 so it can't move down to domain. Documented as an intentional
 exception in the wm-layered contract, mirroring the existing
 wm-domain-purity carve-out for context.py.
- Five call sites (_api_handlers/_workspace.py, services/
 runner_start_service.py, runner/runner_manager.py x3) reached back
 into wm_server.py/services via deferred imports inside function
 bodies to broadcast client notifications, forward ER logs, and
 dispatch ER-initiated action runs. Replaced with two new bridge
 modules (runner/wm_bridge.py, runner/run_dispatch_bridge.py),
 mirroring the existing runner/knowledge_bridge.py pattern: a
 Protocol + install/handlers slot living in the lower layer, filled
 by the higher layer at import time. The ER-dispatch handler bodies
 moved out of runner_manager.py into services/run_service/
 er_dispatch.py.
- config/read_configs.py called runner.runner_client directly to
 resolve py-preset install paths via an already-running dev_workspace
 runner. Split read_project_config into a pure read/merge pair
 (read_project_config_sources / finish_project_config) and moved the
 RPC-dependent preset resolution into a new runner/preset_resolution.py,
 which runner_manager.py (the only caller that ever needed it) now
 calls directly. No opaque object/Any typing needed anywhere in this
 path.
Verified with lint-imports (5/5 contracts kept, was 1 broken) and the
unit suite (87 failed/239 passed before and after — pre-existing
pytest-asyncio gap in this venv, confirmed via git stash comparison).
Ruff, black and isort each guessed a language level on their own --
ruff defaulted to py38, black had none configured at all -- so a
project's declared requires-python, its lint target, and its format
target could all name different Python versions with nothing to
notice the disagreement.
Add get_src_artifact_toolchain_range as the one place that range is
read (from requires-python for Python, via a new handler in
fine_python_package_info), shared with the interpreter axis derivation
in sync_python_interpreters so both read requires-python the same way.
Black and ruff now derive their target version from it when not
explicitly configured; an explicit value still wins.
Ruff's target version and format/lint settings are pushed to its
shared LSP server only during initialize, and deriving the version now
requires an async action call, so a handler can no longer just build
its settings dict in the constructor and call update_settings.
RuffLspService instead collects a settings provider from each handler
and resolves them all -- deep-merged so a linter's and a formatter's
contributions to the same table don't clobber each other -- right
before the server actually starts, regardless of which handler gets
there first.
Also:
- Add extend_ignore to the ruff lint handler, mirroring extend_select,
 so a preset can turn rules off without discarding a project's own
 [tool.ruff.lint] ignore list.
- Expand fine_python_lint's default rule selection (PLE, ASYNC, UP,
 DTZ, C4, PIE, PERF, FURB, SIM, RUF, FLY, INT, ICN, TID, PGH, LOG)
 now that a wrong target-version can no longer silently skew what
 these rules report.
- Bump ruff to 0.16.* and pyrefly to 1.2.*.
- Fix wm_server code using PEP 695 syntax (type X = Y, class Foo[T])
 that requires 3.12, which the presets' own requires-python (>=3.11)
 does not guarantee -- caught once ruff started targeting the range
 it actually declares instead of its py38 default.
Editing FineCode-managed code required restarting the whole workspace
server by hand to see the effect take hold, and there was no way for a
client to survive that restart without reconnecting manually.
Adds a four-rung recovery ladder, cheapest first, each covering what the
one before it doesn't (ADR-0073, ADR-0075):
- reload_action: re-imports the packages owning an action and its
 handlers, in every environment of every target project. Starts no
 process.
- restart_runner: replaces a project's extension runner processes, for
 edits to shared code no single action owns.
- reload_config: re-reads pyproject.toml/finecode.toml/presets and
 replaces the runners they configure -- the rung to reach for when
 unsure which kind of edit was made. ADR-0078 makes the target
 explicit: one project, or the whole workspace via allProjects, never
 defaulted into.
- restart_wm: replaces the workspace server process itself, for edits
 to FineCode's own code.
Recovery is a WM capability the client only projects or refuses
(ADR-0077): each command needs --shared-server, since recovering a
dedicated server the command itself started would report success while
changing nothing that outlives the command. `server/reset` is gone
(ADR-0076) -- it logged one line and returned {}, indistinguishable
from a reset that worked; `workspace/reloadConfig` with
allProjects/rescan replaces it.
A reload or restart is refused while a run is in flight in its target
project, since replacing runners kills whatever they're executing with
no way to tell the caller whether side effects happened;
killInFlightRuns overrides it by name (ADR-0079). Runs the WM started on
its own behalf are exempt -- re-derivable and awaited by nobody, they're
cancelled instead of blocking (ADR-0080). Both are tracked in the new
in_flight_runs registry.
Restarting a shared WM disconnects every other client, so ApiClient
grows reconnect-with-backoff and session re-attachment (ADR-0074): a
dropped connection retries with jittered exponential backoff inside the
WM's 30s disconnect timeout, then replays whatever session state the
surface needs -- add_dir, MCP tool-list invalidation, the LSP's
server_initialized gate -- through a single on_reattach hook shared by
first-connect and reconnect.
MCP exposes the four rungs as tools rather than actions, since an
action executing inside the runner it would replace can't complete.
Each tool's description states what it covers and what to reach for
next: staleness is never auto-detected (ADR-0075), so the description
is the only signal a caller has for which rung applies.
Also:
- schema_utils: describe nested dataclasses (e.g. Range, Position) as
 JSON object schemas instead of {}, needed to expose the new tool
 parameters correctly.
- next_step.for_runner_failure derives the `prepare-envs` command a
 failed recovery should be retried with from the runner's own failure
 state (PRD-0008 R11), instead of surfacing a raw import error.
- New docs/guides/wm-server-internals.md describes the WM layer stack
 and the recovery flow end to end.
Code actions were offered but had no way to be executed: the LSP
codeAction/resolve request had nothing behind it, and there was no
action to turn a get_lint_fixes/get_code_actions result into a write.
This adds the missing writing half of the code action pipeline.
- resolve_code_action: recovers a code action's edits from the
 (provider, action_id) pair round-tripped through the LSP `data`
 field, since the client only ever holds an opaque handle, not the
 edits themselves. Concurrent handlers each claim only the actions
 their own provider minted (lint_fixes_resolve_bridge_handler checks
 `provider == PROVIDER_ID`) and pass through untouched otherwise, so
 one resolve request fans out safely to every registered provider.
- apply_code_actions: applies a batch of selections (each pinned to
 the file version it was computed against) via a new text-edit
 algebra (_text_edit_algebra) that applies same-file edits
 back-to-front so ranges stay valid, detects overlaps, and reports a
 per-selection ApplyOutcome (applied / deferred / version_conflict /
 invalid_range / unresolved / unsupported_operation / write_failed /
 partially_applied) rather than failing the whole batch on one bad
 selection. Only TextEditOperation is executed; Create/Rename/Delete
 are refused wholesale until IFileEditor grows support for them.
- apply_lint_fixes / apply_lint_fixes_files: the fix-oriented entry
 points, layered on top of apply_code_actions so lint fixes and
 editor-offered code actions converge through the same apply and
 version-conflict handling instead of two parallel write paths.
The LSP code_actions endpoint now carries {provider, action_id,
file_path} in each CodeAction's `data` instead of a bare action_id,
and adds a cattrs structure hook to disambiguate the
CodeActionOperation union on the way back in (cattrs' default
disambiguator can't tell CreateFileOperation and DeleteFileOperation
apart from a shared file_path with only defaulted fields of their
own).
Also catches docs/reference/lsp-protocol.md up to the reloadConfig/
restartWm rename that shipped in fbdf049 but never touched this file.
fbdf049 (PRD-0008) described ADR-0080 in its commit message -- runs
the WM starts on its own behalf, re-derivable and awaited by nobody,
should be cancelled by a config reload instead of blocking it -- but
never wired the plumbing: InFlightRun had no cancellable field and
config_reload_service always refused on any run regardless of who
started it. This finishes that:
- InFlightRun.cancellable (domain.py) and in_flight_runs.track/
 blocking_runs now drop cancellable runs before checking whether a
 project is idle, only falling back to naming a blocking run when a
 user-started one is present alongside them.
- run_action, run_actions_in_running_project/_in_projects and
 WorkspaceExecutor.run_actions thread a cancellable flag through to
 the track() call, so a caller can opt a whole dispatch in; it is a
 property of the dispatch, not the action.
- shutdown_service flushes knowledge_service's pending fact writes
 before stopping runners, since a graceful shutdown has no reason to
 accept the throttled writer's usual crash-window loss.
- New integration tests exercise both halves: a lone cancellable run
 lets reloadConfig proceed, and a user run sharing the project with
 one still refuses and names only the run that matters
 (on-demand-extraction-plan D-8 AC7).
Also catches docs/wm-protocol.md and docs/cli.md up to the PRD-0008
rungs that fbdf049 shipped without documenting: reload_action and
restarts_runner's MCP exposure, allProjects/rescan addressing,
server/getInfo's pid and clients fields, and a recovery-ladder
quick-reference table in cli.md.
tests/integration/conftest.py exposes InProcClient.ws_context so
tests can seed in-flight runs and read workspace state back directly.
...project boundaries
Two independent fixes that landed together:
Ruff's CLI (`ruff check`) and LSP paths reported the same violation at
different positions: dropping the 1-based-to-0-based column shift put
every CLI-path range one character to the right of what the LSP path
and an editor agree on, so highlights covered the wrong span and
`payload.range` filtering missed matches across the two paths.
`_position_from_ruff` now does both the row and column shift in one
place. Also `_run_cli_fixes` was calling `ICommandRunner.run` with an
argv list where it takes a single shell string -- the CLI path only
ever worked against a stub; `shlex.join` fixes the real call.
Ruff reports fixability inline with each violation (`fix` non-null)
but the LSP protocol has no field for it, so `Diagnostic.fixable` is
threaded through from ruff's own `data` on the LSP path and from the
CLI's `fix` field on the other, with `None` kept distinct from `False`
so "no fix" isn't confused with "tool doesn't say." The CLI text
report marks fixable diagnostics with `[fixable]`.
LSP code actions carry no applicability, and ruff offers unsafe fixes
as quickfixes regardless of configuration, so a fix arriving over LSP
could not be told apart from a safe one. `_label_applicability` now
cross-references each LSP fix against a same-content `ruff check`
call (by code + position) to recover its real applicability, and
flags noqa-suppression fixes as `DISPLAY_ONLY` since they suppress
rather than fix.
Separately, `list_src_artifact_files_by_lang`'s Python and TOML
handlers walked their project directory with a plain `rglob`, which
does not stop at a nested project's root -- a workspace operation
scoped to the outer project silently pulled in the inner project's
files too, and an unscoped one processed them twice. New
`workspace_utils.nested_project_dirs` / `walk_project_files` prune the
walk at any nested project's root (using workspace project info that
was already available) and skip hidden directories/files (venvs,
`.git`, dotfile configs) by default.
The knowledge model engine (entity/fact model, query IR, interpreter,
memoization DAG) previously lived alongside FineCode's own schema and
rules. R20 requires the core to carry no language- or tool-specific
logic, but that was only a lint-enforced convention -- nothing stopped
rule code from ending up in the same distribution the WM imports to
run the DAG.
Splitting it into its own package makes R20 a packaging fact instead:
finecode_knowledge has zero dependencies and no schema of its own:
schema, providers and rules are declared by a separate distribution
(fine_knowledge) and handed in as a SchemaRegistry via
set_default_registry. The WM can import the engine without any rule
code entering its process, because the package holding rules is never
installed in its environment.
resource_uri.py already flagged the bug in a NOTE: a relative
file:// URI reaching an ER resolves against that ER's own project
directory, not the user's terminal directory, so the same payload
silently names a different file per project a run fans out to. The
CLI is the last process that still knows the user's directory, so
that is where expansion has to happen.
- resource_uri.py gains absolutize_resource_uri, resource_location_to_uri
 and is_relative_file_uri, splitting out _parse_file_uri_path so the
 netloc/path reassembly ("file://relative/path" splits in two under
 urlparse) is shared instead of duplicated.
- New cli_app/payload_uris.py walks an action payload against the
 fields its own payload schema (actions/getPayloadSchemas) marks
 format: "uri", and rewrites only those to absolute file:// URIs —
 a plain path or a relative URI is accepted, but only on a field a
 schema vouches for as a resource.
- run_cmd.run_actions calls it before dispatch and now fails the run
 if a relative file:// URI survives, naming the field and whether a
 schema was even available, rather than sending something an ER
 would resolve wrong.
- mcp_server: stop advertising a "project" tool argument for
 workspace-scoped actions, since the WM always resolves those to
 the workspace root itself and rejects an explicit one; and replace
 the top-level try/except KeyboardInterrupt with
 contextlib.suppress.
Project paths on finecode/runActionInWorkspace come from a handler's
payload, typically a caller-supplied URI some ER turned into a path,
so the WM has never vetted them. An unknown path used to reach a bare
dict lookup deep in the fan-out (proxy_utils.run_actions_in_projects
or the actions_by_project dict in er_dispatch), and the caller was
handed a KeyError whose entire message was the repr of a PosixPath:
it named the path but never said what it had failed to match.
- er_dispatch and proxy_utils now check requested paths against
 ws_context.ws_projects up front and raise errors.ProjectError
 naming the path, the requesting runner, and the closest known
 projects by shared path segments (capped at 3, "and N more") so a
 hundred-project workspace doesn't bury the answer.
- partial_results_service rejects an explicit project path passed
 alongside a workspace-scoped action, mirroring the non-streaming
 actions/run guard in _helpers.py, instead of silently narrowing a
 workspace-wide action to one runner.
- run_dispatch_bridge documents the new ProjectError case.
AsyncProcess only ever buffered a child's stdout/stderr and handed it
over on wait_for_end(), so any handler wanting live output had no way
to get it -- get_output()/get_error_output() carried "TODO: live
output?" comments marking exactly this gap. stdout_lines()/
stderr_lines() close it without disturbing the ~20 existing handlers
that still read output the old way.
- ICommandRunner gains stdout_lines()/stderr_lines() on IAsyncProcess
 only -- the sync flavour has no way to interleave reads with
 anything else.
- command_runner.py: _LineStream drains a stream unconditionally from
 spawn (an unread pipe fills its OS buffer and blocks the child
 forever), accumulates until a subscriber shows up, then switches to
 a queue and stops accumulating. A stream that hits an unrecoverable
 error (a line past the reader's size limit, or non-UTF-8 output)
 keeps draining and discarding rather than stopping, so one bad
 stream cannot hang the whole process. The per-line limit is raised
 well past asyncio's 64 KiB default, since an overrun there discards
 the buffered bytes before raising rather than just reporting it.
- CommandRunner now keeps strong references to its release-when-done
 tasks. The event loop only holds weak ones, so a task with nothing
 referencing it could be collected before the child exits --
 permanently losing a semaphore slot and eventually wedging the
 runner at its concurrency cap.
- Test doubles across the ruff, uv and git presets/extensions gained
 stdout_lines()/stderr_lines() to keep satisfying the IAsyncProcess
 protocol.
A shared server started by run --shared-server still exits 30s after
the last client disconnects — the flag amortizes nothing if no
process holds the server up between calls, so config loading and
runner startup were still paid on every command in the devcontainer.
- start-wm-server gains --keep-alive (disables both the no-client and
 disconnect auto-stop timers; server/shutdown still stops it) and
 --detach (start it in the background, no-op if one is already
 listening). Keep-alive is passed explicitly and never read from the
 environment, so it can't leak onto the dedicated per-command
 servers, which must keep auto-stopping.
- wm_lifecycle.ensure_running forwards keep_alive/disconnect_timeout/
 wal_enabled to the spawned server and starts it in its own session,
 so it survives the signals of whichever short-lived client or
 script happened to start it.
- .devcontainer/start-wm-server.sh runs start-wm-server --detach
 --keep-alive from postStartCommand, gated on FINECODE_WM_AUTOSTART
 (on by default in .env.example) and skipped when the dev_workspace
 venv doesn't exist yet.
A run_agent_task action lets FineCode delegate a task to an AI coding
agent without a caller committing to a specific backend: the fine_agent
preset registers the action slot alone, and a backend extension supplies
the one handler. fine_system_claude_code is renamed to
fine_agent_claude_code to sit alongside the new fine_agent_pi extension
under that naming, and ICommandRunner gains process-group teardown so a
handler can actually stop a hung agent run -- signalling the shell alone
leaves the tools it spawned running.
- presets/fine_agent: RunAgentTaskAction, RunAgentTaskRunPayload/Result,
 and AgentRunUsage carrying only what a backend actually reported,
 never a derived total or a converted currency.
- extensions/fine_agent_claude_code (renamed from fine_system_claude_code):
 ClaudeCodeAgentHandler drives the Claude Code CLI over its
 stream-json protocol.
- extensions/fine_agent_pi (new): PiAgentHandler drives pi.dev over its
 own RPC-like wire format (pi_rpc.py), which is not JSON-RPC and has
 several surprises documented there (agent_settled vs agent_end,
 doubled usage figures across message_end/turn_end).
- finecode_extension_runner/impls/command_runner.py: run(...,
 new_process_group=True) starts the child in its own session;
 is_alive()/terminate()/kill() signal the whole group so a handler's
 escalation ladder (SIGTERM, wait, SIGKILL) reaches an agent's own
 tool-call subprocesses, not just the wrapping shell.
- docs/reference/services.md documents the new stop-a-process pattern.
fine_git could only push tags, so nothing in the workspace could read a
project's git state or discard local changes through an action -- a
caller had to shell out itself. get_git_status/get_git_diff report both
porcelain columns and both diff projections (patch and parsed lines) in
one answer, since callers want different slices of the same state
rather than each parsing it themselves; restore_git_files requires an
explicit path list, since it destroys uncommitted work and "restore
everything" has no place being one flag away.
- get_git_status_action.py / git_get_git_status_handler.py: reports
 repo_root=None for a project outside a git repo as a result, not an
 error.
- get_git_diff_action.py / git_get_git_diff_handler.py: source picks
 worktree/staged/worktree_and_head; context_lines=0 yields hunks with
 only changed lines.
- restore_git_files_action.py / git_restore_git_files_handler.py: a
 path outside the project directory is refused and reported in
 skipped rather than restored; deleting an untracked path needs
 remove_untracked=True since it is unrecoverable.
- tests/conftest.py: FakeCommandResult/FakeCommandRunner moved out of
 test_create_git_tag_handler.py so the three new handlers' tests can
 share them.
Aksem added 15 commits August 16, 2026 05:02
The shared cache file (cache/finecode/results/<action>.json) is
read-modify-written on every run, so a reader can't tell entries this
run produced from ones left by earlier runs against other projects.
An AI caller or script scripting a single `run` invocation needs a
record of exactly what that run did and nothing else.
- cli.py: _write_run_results_file writes a JSON document scoped to
 the single run (request, per-action per-project return codes and
 results) via a temp-file-plus-rename so a concurrent reader never
 sees a half-written document. Written on every exit path, including
 failures before any action executed, so a failed run can't leave a
 stale previous file in place. Implies save_results so the structured
 data exists even under --no-save-results, which only suppresses the
 shared cache.
- RunActionsResult gains scope_by_action_source and
 project_paths_requested so the results file can record each action's
 declared scope -- a workspace-scoped action files its result under
 the project that hosted it, not the projects it was pointed at, and
 nothing else in the result says which case applies.
- run_cmd.py: _build_streaming_result threads scope and resolved
 project paths through from the WM's batch result.
A handler sometimes needs a decision only a person can make mid-run,
and IUserMessenger can't provide it: those three methods broadcast a
string to everyone connected and cannot fail, they don't address one
person or wait for an answer. This adds a proper ask/answer channel
end to end - ER handler, WM, and both client surfaces - addressed to
the specific connection that started the run rather than broadcast,
since two clients can be running the same project at once.
- finecode_extension_api/interfaces/iuserprompt.py: IUserPrompt.
 ask_choice() returns an ElicitationResult with three outcomes
 (answered/declined/unavailable) rather than two, so a handler can
 tell "a person refused" from "no person was there" and react
 differently - abort vs. take the pipeline's default.
- finecode_extension_runner: run_context.py is a contextvar naming
 the run each task belongs to, set once at dispatch in er_server.py
 and inherited by everything the run starts; impls/user_prompt.py
 calls the WM's new finecode/elicit back-channel through it and
 turns every failure mode (transport error, WM error response, no
 WM at all) into UNAVAILABLE, except run cancellation, which
 propagates instead of looking like an unattended run.
- wm_client.py: ApiClient can now receive server-to-client *requests*,
 not just notifications - the first traffic on this connection that
 isn't ER-work-driven. Fixes a latent bug in the read loop, which
 tested "id present" to mean "response" and would have silently
 mis-routed any inbound request as a response to an unrelated id.
- wm_server.py: negotiates the capability at client/initialize (a
 pipeline or non-interactive client declares nothing, so its runs
 get "unavailable" immediately instead of waiting out a deadline),
 addresses a request to one connection with its own deadline, and
 resolves every pending question at once when that connection drops.
- elicitation_bridge.py: the runner-layer slot finecode/elicit comes
 in through, plus the run-id -> connection registry it's resolved
 against. Bound in in_flight_runs.track, since a run's addressability
 and its in-flight lifetime are the same thing; nested dispatch
 (er_dispatch.py) inherits the calling run's connection so a question
 asked several hops into a fan-out still reaches the right person.
- walRunId is renamed to runId throughout the ER protocol: it was
 already the WM's per-dispatch identifier and ADR-0079 already keys
 in-flight runs by it, so reusing it to address elicitation avoids
 minting a second id with the same lifetime.
- run_cmd.py: the CLI's side of client/elicit prints to stderr (so
 piped stdout is unaffected), serializes concurrent ERs onto one
 lock so two questions can't race the same stdin, and runs the
 blocking prompt on a daemon thread so a torn-down run doesn't wait
 on an unanswered prompt to exit. Declares the capability only when
 stdin is a tty.
- mcp_server/server.py: forwards to the AI client via MCP's
 elicitation/create, mapping its three outcomes (accept/decline/
 cancel) onto ours, and bumps the negotiated protocol version to
 2025年06月18日 where that request exists.
- tests/unit/test_run_results_file.py: test coverage for the
 --results-file feature that was missing from its own commit.
The ContextVar-based originating_client() let the connection an ER
should ask questions of leak into ambient state, which is invisible
at call sites and silently defaults to "nobody" if a hop forgets to
set it — exactly the failure the two progressToken handlers in
_streaming.py had (a connected client whose run could never be
asked). Passing RunDispatchOrigin explicitly, with no default at any
hop, turns a dropped connection into a signature the type checker
can catch instead of a question nobody receives (ADR-0082).
- elicitation_bridge.py: RunDispatchOrigin(connection) replaces the
 originating_client() context manager; bind_run() now takes it as a
 required argument instead of reading the ContextVar.
- er_dispatch.py, in_flight_runs.py, proxy_utils.py,
 matrix_streaming.py, project_executor.py, execution_scopes.py,
 workspace_executor.py, partial_results_service.py: thread `origin`
 explicitly from each request handler down through every dispatch
 seam to the choke point (in_flight_runs.track) that binds it.
- _actions.py, _streaming.py, cli_app/utils.py,
 prepare_envs_service.py: each call site states its origin
 explicitly, including the plain request/response and internal
 dispatch paths that have none.
- developing-finecode.md: documents the "ambient state" rule this
 refactor follows (when a ContextVar is justified vs. when a
 parameter is required with no default), plus the boolean-flags,
 comments, knowledge-package-split and local-observability-stack
 sections accumulated in this file since the last commit touching
 it.
A lint fix or refactor that needs to create, rename, or delete a file
had no way to express that: CodeAction only ever carried a flat map of
text edits. This extends the operation model end to end so a handler
can emit an ordered list of text-edit and resource operations, and the
LSP endpoint negotiates what the connected editor can actually apply
before offering or resolving an action built from them.
- ifileeditor.py / file_editor.py: IFileEditorSession gains create_file,
 delete_file, claim_file and file_exists so a handler can build these
 operations without reaching around the interface. claim_file covers
 the not-yet-existing case that modify_file's claim can't; claims are
 now reentrant within one task rather than deadlocking on renesting.
- code_action_types.py / apply_code_actions_action.py: CodeAction.edits
 (an unordered dict) becomes CodeAction.operations, an ordered list of
 CreateFileOperation/RenameFileOperation/DeleteFileOperation/
 TextEditOperation — the same shape both resolve and apply speak.
- code_actions.py (LSP endpoint): negotiates documentChanges support and
 declared resourceOperations from the client's initialize capabilities
 (lsp_server.py), and converts an operation list into a WorkspaceEdit
 only when the client can express it — falling back to the `changes`
 map when documentChanges isn't supported, and dropping (offer) or
 refusing (resolve) an action the client can't represent rather than
 flattening it into something it didn't ask for. codeActionProvider
 now advertises resolveProvider so resolve is actually reachable.
- apply_workspace_edit_bridge.py: replaces the bare module-level
 `apply_workspace_edit` callable in runner_manager.py with a slot
 matching elicitation_bridge/run_dispatch_bridge's pattern — an
 unfilled slot fails the waiting caller explicitly instead of crashing
 on a None call. runner_manager forwards documentChanges as-is
 (order-preserving) and checks each resource operation's kind against
 what the bridge reports the editor supports before sending it.
- developing-finecode.md: records the --shared-server workflow (why it
 matters, when to drop it, how to narrow a check to one project or
 file) worked out while developing this change, plus the enum and
 NamedTuple conventions this change's own types follow.
An unknown --field=value name or a scalar where an action declares a
list used to be accepted silently: the ER ignored the unknown field
and cattrs' default union/list handling coerced a string into
characters or passed a mismatched type straight through, so mistakes
surfaced later as wrong behavior instead of an error.
- payload_uris.coerce_raw_value parses each raw CLI string guided by
 the field's schema (string/boolean/integer/number/array/object,
 plus enum membership), falling back to the existing blind JSON parse
 where the schema vouches for nothing. run_cmd._resolve_payload (was
 _absolutize_payload_resources) refuses unknown field names with a
 close-match suggestion, unless any requested action's schema is
 unavailable, in which case the name check is skipped entirely.
 Fields routed through --map-payload-fields keep their placeholder
 value untouched.
- finecode_extension_runner._converter gains a second `payload_converter`
 used only for action payloads: strict bool/int/float/str hooks and a
 strict list/union structuring so a wrong-typed value raises instead
 of being silently coerced or passed through. The shared `converter`
 (results, run state, handler/service config) is left untouched, and
 extra keys are still tolerated there since cross-env subaction
 dispatch depends on it.
- schema_utils gains the shared FieldSchema/PayloadSchema/JsonValue
 vocabulary these two sides speak, marked as plumbing to move into a
 common package once one exists.
- RunActionsResult.resolved_payload records what actually ran, so
 --results-file reflects the resolved payload rather than the raw
 blind parse.
- developing-finecode.md: documents the new CLI validation behavior,
 folds the now-superseded run_tests relative-path pitfall into the
 same "payload field" account, and adds the Any/sentinel/bare-generic
 typing conventions worked out while writing the strict converter.
- docs/reference/actions.md: add reference sections for apply_code_actions
 and run_agent_task, which landed in prior commits (ADR-0083, ADR-0082)
 without ever getting a reference entry; document
 group_src_artifact_files_by_lang's per-project pruning behavior and why
 it matters for correctness and performance; correct file_paths fields to
 ResourceUri and add the fixability field to lint/audit_code results, so
 the reference matches what the schemas actually declare.
- docs/guides/package-naming.md, creating-preset.md: rewrite the naming
 convention for ADR-0081 — the segment before the role word is now a
 domain (language or preset family), not just a language, and the
 role-word vocabulary is open rather than a closed table to keep in sync.
- _converter.py: raise TypeError instead of ValueError from the strict
 structuring hooks (ruff TRY004), and read the list item type via
 typing.get_args instead of cls.__args__, which raises AttributeError on
 a bare `list` annotation before the Any fallback can apply.
- run_context.py: document why the run id is ambient (ContextVar) instead
 of a threaded parameter — the consumer outlives the run and the
 alternative is a public API change that pushes the responsibility onto
 every handler author — and contrast it with RunActionMeta.wal_run_id,
 which carries the same identifier but under handler control.
- wm_client.py: type get_payload_schemas' return as
 schema_utils.PayloadSchema | None instead of a bare dict, matching what
 the WM actually returns; drop the redundant BLE001 noqa on the
 request-handler catch-all now that the comment above it fully documents
 the justification.
The three independent concurrency layers (prepare-envs' sqrt-split
projects/envs caps, run fan-out's per-project semaphore, and each ER's
CommandRunner cap) each guessed a slice of the machine's real capacity
without knowing what the others were doing, and composed multiplicatively
in the worst case. A precommit hook made it concrete: each file-based
bridge handler (lint, format, type_check, inspect_code, audit_code) fanned
out one concurrent workspace call per staged project, each free to spawn
up to its own ER's subprocess cap, so a modest multi-project commit could
launch far more subprocesses than any single layer's math accounted for.
- services/process_budget.py: the WM now owns one machine-wide budget of
 subprocess work slots, sized from FINECODE_MAX_CONCURRENT_PROCESSES or
 the machine's usable CPU count. ERs lease slots per action run over
 finecode/leaseProcessBudget and release them over
 finecode/releaseProcessBudget; runner_manager reclaims a runner's
 outstanding leases on stop, crash, or a failed start so slots never
 leak. A nested lease always gets at least one slot so a run started by
 another run's fan-out can never deadlock waiting on the same budget.
- process_slots.py (new, ER side): CommandRunner and ProcessExecutor now
 draw from one shared, resizable gate per ER instead of each keeping its
 own semaphore — a condition variable rather than an asyncio.Semaphore,
 since a shrinking target must block new grants without revoking slots
 already held. The WM pushes resize targets down via
 finecodeRunner/updateProcessBudget, a process-level tweak that does not
 rebuild RunnerContext.
- CommandRunner's config.max_concurrent_processes and prepare-envs'
 --max-concurrent-projects/FINECODE_WM_*_MAX_CONCURRENT_PROJECTS are
 removed as independent layers; the service-config cap survives as an
 optional additional ceiling on top of the shared budget.
- IWorkspaceActionRunner gains run_action_per_project, letting a caller
 send one workspace call with a complete payload per project instead of
 opening one TaskGroup-fanned-out call per project. The wire protocol
 carries this as payloadOverridesByProject, shallow-merged by the WM
 against an empty base payload. Every file-based precommit bridge
 handler switches to it, cutting a 3-project staged commit from three
 concurrent workspace calls to one.
See ADR-0090 for the full rationale and docs/guides/wm-server-internals.md
("Process budget") / docs/guides/preparing-environments.md ("Bounding
concurrency") for the updated operator-facing picture.
...-0072, ADR-0028)
goals.md §4.10 puts the WM in charge of the logical fact store and the
memo table, but nothing before this owned either: an ER had no path to
send `knowledge/query` or `knowledge/registerSchema` requests, and the
DAG needed to memoize a query and refresh a stale bucket on demand did
not exist anywhere in the WM.
- services/knowledge_service.py: loads the fact store, runs R11's
 confirmation walk, holds the schema an ER registers, executes queries
 over the interpreter, and memoizes them keyed to the fact DAG's
 revision. Mode.VERIFIED re-checks tracked inputs before answering;
 Mode.CACHED skips that walk for a live WM's latency-sensitive callers
 (ADR-0014 D6). A stale bucket a walk discovers is refreshed by
 dispatching a scoped `extract_knowledge` run into an ER and ingesting
 the facts it returns, deduped per bucket and capped in flight so
 concurrent queries needing the same bucket cost one round trip.
- runner/knowledge_bridge.py: the installable slot (ADR-0072) an ER's
 JSON-RPC client calls into, so the runner layer never imports the
 service that sits above it in the WM's layer stack.
- finecode_knowledge/query/validate.py: adds `_warn_lookup_literals`,
 the mirror of `_warn_unconstrained_key_bindings` -- it flags a FIELD
 literal that exists only to bind a head `Prov`, which in a conjunctive
 language is an existence test the author never asked for. ADR-0028 is
 the case this closes: `expected_in` was bound by adding a `def_path`
 literal to two rules, so a project missing that fact produced no
 finding at all even though the real violation held.
start_own_server() (used by prepare-envs, run, dump-config, bootstrap)
sent the subprocess's stderr to DEVNULL, so a crash before the server's
own logger initializes left no trace anywhere. The resulting timeout
error told the caller to "check logs for errors" that never existed.
Capture stderr to a file instead (creating its parent dir, which
ensure_running gets for free but this path doesn't), and inline its
content into the raised error so it survives even when the disk that
held it is gone by the time anyone looks (e.g. a CI runner).
Claude-Session: https://claude.ai/code/session_01XaMRTq9Sk67d8x8zEm7wBJ 
is_valid_venv() ran `finecode version` (not a real command, always fails)
then fell back to `finecode --help`, which click resolves without ever
invoking a command body. Neither check imports anything the WM server
actually needs, so a broken/stale cached venv -- e.g. one where
finecode_knowledge top-level imports fine but a submodule prepare-envs's
dedicated WM subprocess needs (`fact_file`) does not -- passes validation
and CI skips reinstalling. The failure only surfaces later, as
prepare-envs's dedicated server crashing on import and burning its full
30s startup timeout before the (now-captured, see previous commit)
traceback explains why.
Import the actual module prepare-envs starts instead, so this is caught
immediately and the venv gets rebuilt rather than silently reused broken.
Claude-Session: https://claude.ai/code/session_01XaMRTq9Sk67d8x8zEm7wBJ 
is_valid_venv() called `finecode version` -- not a real command, always
failed -- then fell back to `finecode --help`, which click resolves
without invoking any command body. Neither check exercises anything the
WM server actually needs, so a broken/stale venv (e.g. one where
finecode_knowledge imports fine at the top level but its `fact_file`
submodule doesn't -- the exact crash the previous commit's stderr
capture surfaced from CI) passes validation and CI skips reinstalling.
Add `finecode version` as a real command: it starts (or attaches to) the
WM server and asks it for its version via server/getInfo, rather than
reading local package metadata -- proving the server's full startup path
(spawn, import, bind, respond) actually completes, which is what every
other client depends on and what actually breaks. This checks the
server end-to-end rather than reaching into its internals the way an
`import finecode.wm_server.wm_server` probe would.
is_valid_venv() now calls this one real command instead of the dead
duplicate-and-fallback chain. Verified it correctly reports the venv
invalid (and prints the real crash) when fact_file.py is removed, and
valid on a healthy install.
Claude-Session: https://claude.ai/code/session_01XaMRTq9Sk67d8x8zEm7wBJ 
The extension renames and additions from recent commits never got their
config-side registration:
- Depend on finecode_knowledge/fine_knowledge and add the fine_knowledge
 preset, wiring in the WM-side knowledge service from 3ba945a.
- Point install_claude_code at fine_agent_claude_code (the package was
 already renamed from fine_system_claude_code) and register the new
 install_pi handler for fine_agent_pi.
- Add the wm_server.context -> services.process_budget ignore_imports
 exception for the ADR-0090 process budget (c0f9fc4): context.py has
 imported it since that commit, but the contract was never updated,
 so audit_code would have flagged it.
Also re-run the TOML formatter, which reflows arrays to 2-space indent
and alphabetizes dependency lists.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /