Skip to content

Commit ae6edaa

Browse files
committed
ADR-0016: layered execution scopes. Initial implementation (partial)
1 parent 7d3aff4 commit ae6edaa

20 files changed

Lines changed: 742 additions & 21 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# ADR-0016: Layered execution scopes for action invocation
2+
3+
- **Status:** accepted
4+
- **Date:** 2026-04-05
5+
- **Deciders:** @Aksem
6+
- **Tags:** actions, architecture, orchestration, extension-runner, wm-server
7+
8+
## Context
9+
10+
Action invocation in FineCode occurs across architectural components and
11+
multiple execution scopes. In particular, FineCode distinguishes the Workspace
12+
Manager (WM) from Extension Runners (ERs), and invocation can cross those
13+
boundaries:
14+
15+
- one action may invoke another within a single ER runtime boundary
16+
- one project-scoped request may require coordination across multiple execution
17+
environments and runners
18+
- one workspace-scoped request may fan out across multiple projects under WM
19+
coordination
20+
21+
These scopes have different topology visibility, ownership boundaries, and
22+
operational controls.
23+
24+
If one contract tries to cover all of them, local invocation semantics become
25+
coupled to orchestration concerns that they do not own. That makes the system
26+
harder to evolve and reason about because:
27+
28+
- local nested action invocation and cross-boundary orchestration have
29+
different guarantees
30+
- recursion and fan-out control apply to orchestration scopes, not local scope
31+
- WM-managed project/workspace topology should not leak into local ER
32+
execution contracts
33+
34+
FineCode needs an explicit architectural rule that distinguishes execution
35+
scopes while preserving generic, action-agnostic orchestration.
36+
37+
## Related ADRs Considered
38+
39+
- Reviewed [ADR-0003](0003-process-isolation-per-extension-environment.md) -
40+
defines environment process isolation; this ADR defines invocation scope
41+
boundaries across those processes.
42+
- Reviewed [ADR-0011](0011-wm-aggregates-progress-across-multi-project-action-runs.md) -
43+
defines WM ownership for aggregated progress in fan-out requests; this ADR
44+
defines execution interface layering for such fan-out.
45+
- Reviewed [ADR-0013](0013-action-declares-handler-execution-strategy.md) -
46+
defines intra-action handler relationship; this ADR defines cross-boundary
47+
invocation scope and ownership.
48+
49+
## Decision
50+
51+
FineCode adopts a layered execution model by scope:
52+
53+
- Local execution scope: invocation within a single ER runtime boundary.
54+
- Project execution scope: invocation for one project under a project-scoped
55+
contract.
56+
- Workspace execution scope: invocation across multiple projects under a
57+
workspace-scoped contract.
58+
59+
### Architectural boundaries
60+
61+
1. Local execution contracts remain local and do not carry project/workspace
62+
topology concerns.
63+
2. Cross-boundary orchestration is represented by separate higher-scope
64+
contracts, not by widening local contracts.
65+
3. Project and workspace orchestration remain generic and action-agnostic, and
66+
are owned by WM-level execution capabilities rather than ER-local contracts.
67+
4. Coordination that depends on shared resources across projects must model
68+
those resources explicitly rather than relying on implicit shared state.
69+
70+
### Ownership rule
71+
72+
- ER-local components own local action invocation semantics.
73+
- WM-level orchestration owns cross-runner and cross-project coordination
74+
mechanics.
75+
- Action-specific orchestration policy belongs to the orchestrating
76+
action/service, not to generic orchestration infrastructure.
77+
78+
## Consequences
79+
80+
- One scope, one contract. Callers can choose local, project, or workspace
81+
execution contracts explicitly.
82+
- Future-safe evolution. Single-project execution can evolve from local to
83+
orchestrated without redefining local contracts.
84+
- No action-specific WM coupling. WM remains a generic execution mechanism for
85+
project/workspace fan-out.
86+
- Additional interface surface. Introducing layered contracts increases
87+
architectural surface area and requires clear naming/documentation.
88+
- Operational safeguards required. Orchestration scopes require recursion and
89+
fan-out controls.
90+
91+
### Alternatives Considered
92+
93+
Directly using one unified action-runner interface across all scopes. Rejected
94+
because it conflates local and orchestration concerns, reducing contract clarity
95+
and increasing accidental coupling.
96+
97+
Extending only the local runner interface to cover project/workspace
98+
orchestration. Rejected because topology-aware behavior is not a local concern
99+
and would blur ownership boundaries.
100+
101+
Keeping orchestration implicit behind internal mechanisms without
102+
scope-specific contracts. Rejected because orchestration would remain implicit
103+
and difficult to reuse consistently from callers that need it.
104+
105+
### Related Decisions
106+
107+
- Complements [ADR-0003](0003-process-isolation-per-extension-environment.md)
108+
by clarifying invocation semantics across isolated runner processes.
109+
- Refines [ADR-0011](0011-wm-aggregates-progress-across-multi-project-action-runs.md)
110+
by defining broader execution-scope layering.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,4 @@ keep the same decision, would the ADR still read correctly?
6666
| 0013 | [Action declares handler execution strategy](0013-action-declares-handler-execution-strategy.md) | accepted | 2026-03-29 | actions, architecture |
6767
| 0014 | [CLI streams partial results in completion order](0014-cli-streams-partial-results-in-completion-order.md) | accepted | 2026-03-31 | cli, partial-results, ux |
6868
| 0015 | [Dedicated per-process WAL streams for durable execution lifecycle events](0015-dedicated-per-process-wal-streams-for-durable-lifecycle-events.md) | accepted | 2026-04-01 | architecture, reliability, recovery, logging, wal |
69+
| 0016 | [Layered execution scopes for action invocation](0016-layered-execution-scopes-for-action-invocation.md) | accepted | 2026-04-05 | actions, architecture, orchestration, extension-runner, wm-server |

finecode_extension_api/src/finecode_extension_api/code_action.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class RunActionMeta:
5656
trigger: RunActionTrigger
5757
dev_env: DevEnv
5858
wal_run_id: str = ""
59+
orchestration_depth: int = 0 # incremented at each cross-boundary hop
5960

6061

6162
class RunReturnCode(enum.IntEnum):
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import collections.abc
4+
import pathlib
5+
import typing
6+
7+
from finecode_extension_api import code_action, service
8+
from finecode_extension_api.interfaces.iactionrunner import ActionDeclaration
9+
10+
PayloadT = typing.TypeVar("PayloadT", bound=code_action.RunActionPayload)
11+
ResultT = typing.TypeVar("ResultT", bound=code_action.RunActionResult)
12+
ActionT = typing.TypeVar(
13+
"ActionT",
14+
bound=code_action.Action[typing.Any, typing.Any, typing.Any],
15+
covariant=True,
16+
)
17+
18+
19+
class IProjectActionRunner(service.Service, typing.Protocol):
20+
"""Run an action at project scope — across all env-runners of one project.
21+
22+
API mirrors IActionRunner: actions are identified by ActionDeclaration
23+
(which carries the import-path source string). No caller_kwargs — caller
24+
context does not cross process boundaries.
25+
26+
The implementation serializes payload to a dict, calls the WM back-channel
27+
with the action's source path, and deserializes the response using the
28+
action class's RESULT_TYPE. This is transparent to the handler author.
29+
"""
30+
31+
async def run_action(
32+
self,
33+
action: ActionDeclaration[code_action.Action[PayloadT, typing.Any, ResultT]],
34+
payload: PayloadT,
35+
meta: code_action.RunActionMeta,
36+
) -> ResultT: ...
37+
38+
def run_action_iter(
39+
self,
40+
action: ActionDeclaration[code_action.Action[PayloadT, typing.Any, ResultT]],
41+
payload: PayloadT,
42+
meta: code_action.RunActionMeta,
43+
) -> collections.abc.AsyncIterator[ResultT]: ...
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import pathlib
4+
import typing
5+
6+
from finecode_extension_api import code_action, service
7+
from finecode_extension_api.interfaces.iactionrunner import ActionDeclaration
8+
9+
PayloadT = typing.TypeVar("PayloadT", bound=code_action.RunActionPayload)
10+
ResultT = typing.TypeVar("ResultT", bound=code_action.RunActionResult)
11+
ActionT = typing.TypeVar(
12+
"ActionT",
13+
bound=code_action.Action[typing.Any, typing.Any, typing.Any],
14+
covariant=True,
15+
)
16+
17+
18+
class IWorkspaceActionRunner(service.Service, typing.Protocol):
19+
"""Fan-out an action across all workspace projects that declare it.
20+
21+
project_paths=None means all projects in the workspace that declare
22+
the action.
23+
"""
24+
25+
async def run_action_in_projects(
26+
self,
27+
action: ActionDeclaration[code_action.Action[PayloadT, typing.Any, ResultT]],
28+
payload: PayloadT,
29+
meta: code_action.RunActionMeta,
30+
project_paths: list[pathlib.Path] | None = None,
31+
) -> dict[pathlib.Path, ResultT]: ...

finecode_extension_runner/src/finecode_extension_runner/_services/run_action.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ async def run_action(
273273
trigger=meta.trigger,
274274
dev_env=meta.dev_env,
275275
tracking_sender=tracking_sender,
276+
partial_result_queue=partial_result_queue,
276277
)
277278

278279
if not isinstance(
@@ -385,6 +386,7 @@ async def run_action(
385386
trigger=meta.trigger,
386387
dev_env=meta.dev_env,
387388
tracking_sender=tracking_sender,
389+
partial_result_queue=partial_result_queue,
388390
)
389391
)
390392
handlers_tasks.append(handler_task)
@@ -421,6 +423,7 @@ async def run_action(
421423
trigger=meta.trigger,
422424
dev_env=meta.dev_env,
423425
tracking_sender=tracking_sender,
426+
partial_result_queue=partial_result_queue,
424427
)
425428
except ActionFailedException as exception:
426429
raise exception
@@ -793,6 +796,7 @@ async def execute_action_handler(
793796
trigger: str = "unknown",
794797
dev_env: str = "unknown",
795798
tracking_sender: _TrackingPartialResultSender | None = None,
799+
partial_result_queue: asyncio.Queue | None = None,
796800
) -> code_action.RunActionResult | None:
797801
logger.trace(f"R{run_id} | Run {handler.name} on {str(payload)[:100]}...")
798802
if wal_run_id is not None:
@@ -861,6 +865,11 @@ def get_run_context(param_type):
861865
stream_result: code_action.RunActionResult | None = None
862866
async for partial_result in call_result:
863867
partial_result = typing.cast(code_action.RunActionResult, partial_result)
868+
# Both paths below forward the partial to a caller — they differ only
869+
# in transport. partial_result_token sends to an LSP/MCP client via
870+
# the WM notification channel; partial_result_queue delivers to a parent
871+
# action handler that called run_action_iter(). These could be unified
872+
# into a single PartialResultForwarder abstraction in the future.
864873
if partial_result_token is not None:
865874
if (
866875
tracking_sender is not None
@@ -881,6 +890,8 @@ def get_run_context(param_type):
881890
await partial_result_sender.schedule_sending(
882891
partial_result_token, partial_result
883892
)
893+
if partial_result_queue is not None:
894+
await partial_result_queue.put(partial_result)
884895
if stream_result is None:
885896
result_type_pydantic = pydantic_dataclass(type(partial_result))
886897
stream_result = typing.cast(
@@ -892,6 +903,8 @@ def get_run_context(param_type):
892903
if partial_result_token is not None:
893904
await partial_result_sender.send_all_immediately()
894905
execution_result = None # partials already sent
906+
elif partial_result_queue is not None:
907+
execution_result = None # each partial already forwarded to queue
895908
else:
896909
execution_result = stream_result
897910
elif inspect.isawaitable(call_result):

finecode_extension_runner/src/finecode_extension_runner/di/bootstrap.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
ifilemanager,
1919
ilogger,
2020
iprojectinfoprovider,
21+
iprojectactionrunner,
2122
irepositorycredentialsprovider,
23+
iworkspaceactionrunner,
2224
)
2325

2426
from finecode_extension_runner import domain
@@ -33,9 +35,11 @@
3335
file_manager,
3436
inmemory_cache,
3537
loguru_logger,
38+
project_action_runner,
3639
project_info_provider,
3740
repository_credentials_provider,
3841
service_registry,
42+
workspace_action_runner,
3943
)
4044

4145
def bootstrap(
@@ -49,6 +53,7 @@ def bootstrap(
4953
current_env_name_getter: Callable[[], str],
5054
handler_packages: set[str],
5155
service_declarations: list,
56+
send_request_to_wm: Callable[[str, dict], collections.abc.Awaitable[Any]] | None = None,
5257
):
5358
# logger_instance = loguru_logger.LoguruLogger()
5459
logger_instance = loguru_logger.get_logger()
@@ -75,6 +80,14 @@ def bootstrap(
7580
_state.container[icache.ICache] = cache_instance
7681
_state.container[iactionrunner.IActionRunner] = action_runner_instance
7782

83+
if send_request_to_wm is not None:
84+
_state.container[iprojectactionrunner.IProjectActionRunner] = (
85+
project_action_runner.ProjectActionRunnerImpl(send_request_to_wm)
86+
)
87+
_state.container[iworkspaceactionrunner.IWorkspaceActionRunner] = (
88+
workspace_action_runner.WorkspaceActionRunnerImpl(send_request_to_wm)
89+
)
90+
7891
_state.container[irepositorycredentialsprovider.IRepositoryCredentialsProvider] = (
7992
repository_credentials_provider.ConfigRepositoryCredentialsProvider()
8093
)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
import collections.abc
4+
import dataclasses
5+
import typing
6+
from typing import Any, Awaitable, Callable
7+
8+
from finecode_extension_api import code_action
9+
from finecode_extension_api.interfaces import iactionrunner, iprojectactionrunner
10+
from finecode_extension_runner import run_utils
11+
12+
PayloadT = typing.TypeVar("PayloadT", bound=code_action.RunActionPayload)
13+
ResultT = typing.TypeVar("ResultT", bound=code_action.RunActionResult)
14+
15+
16+
class ProjectActionRunnerImpl(iprojectactionrunner.IProjectActionRunner):
17+
"""Calls the WM back-channel finecode/runActionInProject."""
18+
19+
def __init__(self, send_request_to_wm: Callable[[str, dict], Awaitable[Any]]) -> None:
20+
self._send = send_request_to_wm
21+
22+
async def run_action(
23+
self,
24+
action: iactionrunner.ActionDeclaration[code_action.Action[PayloadT, typing.Any, ResultT]],
25+
payload: PayloadT,
26+
meta: code_action.RunActionMeta,
27+
) -> ResultT:
28+
action_source: str = action.source # type: ignore[attr-defined]
29+
action_cls = run_utils.import_module_member_by_source_str(action_source)
30+
raw_result = await self._send(
31+
"finecode/runActionInProject",
32+
{
33+
"actionSource": action_source,
34+
"payload": dataclasses.asdict(payload),
35+
"meta": {
36+
"trigger": meta.trigger.value,
37+
"devEnv": meta.dev_env.value,
38+
"orchestrationDepth": meta.orchestration_depth,
39+
},
40+
},
41+
)
42+
result_dict = raw_result.get("result", {}) or {}
43+
return action_cls.RESULT_TYPE(**result_dict)
44+
45+
def run_action_iter(
46+
self,
47+
action: iactionrunner.ActionDeclaration[code_action.Action[PayloadT, typing.Any, ResultT]],
48+
payload: PayloadT,
49+
meta: code_action.RunActionMeta,
50+
) -> collections.abc.AsyncIterator[ResultT]:
51+
raise NotImplementedError(
52+
"run_action_iter is not supported for project-scope execution"
53+
)

0 commit comments

Comments
 (0)