gitgrip 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- gr2_overlay/__init__.py +37 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"""Neutral launch primitive (S5).
|
|
2
|
+
|
|
3
|
+
Executes a LaunchPlan entry: run this argv, in this working directory, with
|
|
4
|
+
these environment KEY NAMES. gr2 cannot tell one caller's workspace from
|
|
5
|
+
another -- it sees an opaque `unit_key`, an argv, and a set of env key names
|
|
6
|
+
whose VALUES the caller supplies in memory at launch.
|
|
7
|
+
|
|
8
|
+
Design: the spawn launch contract, §2 (LaunchPlan is the
|
|
9
|
+
opaque tier) and §5 (cold start, no `--resume`).
|
|
10
|
+
|
|
11
|
+
THE BOUNDARY CORRECTION THIS SLICE EXISTS TO MAKE
|
|
12
|
+
--------------------------------------------------
|
|
13
|
+
gr1's spawn builds an on-disk launch script containing `export KEY=value` lines.
|
|
14
|
+
That puts the caller's environment VALUES on disk -- every declared key
|
|
15
|
+
bindings -- inside the OSS layer, in a file that outlives the launch.
|
|
16
|
+
|
|
17
|
+
The neutral plan carries key NAMES; the values are injected in memory and never
|
|
18
|
+
persisted. This primitive therefore has no script-writing path at all: values
|
|
19
|
+
are passed straight to the child process environment and are unreachable from
|
|
20
|
+
the filesystem afterwards. `test_no_environment_value_reaches_the_filesystem`
|
|
21
|
+
searches the workspace for a value rather than trusting that nothing wrote one.
|
|
22
|
+
|
|
23
|
+
Same rule that held through the whole materializer: no identity in any neutral
|
|
24
|
+
artifact. A launch script IS a neutral artifact.
|
|
25
|
+
|
|
26
|
+
WHAT "LAUNCHED" HAS TO MEAN
|
|
27
|
+
---------------------------
|
|
28
|
+
A spawn call returning successfully is not an agent running. A process that
|
|
29
|
+
exits immediately -- a missing binary, a bad flag, an instant crash -- produces
|
|
30
|
+
exactly the same "success" as one that came up healthy, and the failure surfaces
|
|
31
|
+
later as an empty pane nobody notices. So launch is not acknowledged until the
|
|
32
|
+
child has been observed ALIVE after a settle interval, which is the same
|
|
33
|
+
distinction invariant 6 draws between a venv's files existing and its
|
|
34
|
+
interpreter answering.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import dataclasses
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import socket
|
|
43
|
+
import stat
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
from collections.abc import Mapping, Sequence
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any, Protocol
|
|
50
|
+
|
|
51
|
+
from .spec_apply import MaterializationPlanError, canonicalize_workspace_path
|
|
52
|
+
|
|
53
|
+
# Long enough that an immediate crash (bad binary, bad flag, instant exit) has
|
|
54
|
+
# happened, short enough not to matter against §13's 180s budget.
|
|
55
|
+
_LIVENESS_SETTLE_SECONDS = 0.35
|
|
56
|
+
|
|
57
|
+
# macOS exposes 104 bytes for sockaddr_un.sun_path including the terminating
|
|
58
|
+
# NUL, so 103 encoded path bytes is the portable ceiling this runtime accepts.
|
|
59
|
+
# Linux permits a few more; using the smaller measured limit makes the same
|
|
60
|
+
# explicit socket viable on every supported POSIX host instead of branching on
|
|
61
|
+
# whichever generated workspace path or kernel happens to be present.
|
|
62
|
+
_AF_UNIX_SOCKET_PATH_MAX_BYTES = 103
|
|
63
|
+
_MINIMUM_TMUX_VERSION = (3, 2)
|
|
64
|
+
_TMUX_ROLLBACK_SETTLE_SECONDS = 2.0
|
|
65
|
+
_TMUX_ROLLBACK_POLL_SECONDS = 0.02
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LaunchExecutionError(MaterializationPlanError):
|
|
69
|
+
"""A launch entry could not be executed safely."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclasses.dataclass(frozen=True)
|
|
73
|
+
class LaunchEntry:
|
|
74
|
+
"""One unit's opaque launch declaration.
|
|
75
|
+
|
|
76
|
+
Deliberately carries no model, tool, role or agent name -- gr2 runs an argv,
|
|
77
|
+
it does not know what the argv IS."""
|
|
78
|
+
|
|
79
|
+
unit_key: str
|
|
80
|
+
workdir: str
|
|
81
|
+
argv: tuple[str, ...]
|
|
82
|
+
env_allowlist_keys: tuple[str, ...]
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> LaunchEntry:
|
|
86
|
+
allowed = {"unit_key", "workdir", "argv", "env_allowlist_keys"}
|
|
87
|
+
unknown = set(data) - allowed
|
|
88
|
+
if unknown:
|
|
89
|
+
# Closed by construction, same reason the plan schema is: a field
|
|
90
|
+
# nobody thought to reject cannot smuggle anything if it cannot
|
|
91
|
+
# exist. An unexpected key here is how identity would arrive.
|
|
92
|
+
raise LaunchExecutionError(
|
|
93
|
+
f"launch entry has unknown field(s) {sorted(unknown)} -- the opaque "
|
|
94
|
+
"tier carries exactly unit_key, workdir, argv and env_allowlist_keys"
|
|
95
|
+
)
|
|
96
|
+
for field in ("unit_key", "workdir"):
|
|
97
|
+
if not isinstance(data.get(field), str) or not data[field]:
|
|
98
|
+
raise LaunchExecutionError(f"launch entry {field!r} must be a non-empty string")
|
|
99
|
+
argv = data.get("argv")
|
|
100
|
+
if (
|
|
101
|
+
not isinstance(argv, (list, tuple))
|
|
102
|
+
or not argv
|
|
103
|
+
or not all(isinstance(a, str) for a in argv)
|
|
104
|
+
):
|
|
105
|
+
raise LaunchExecutionError(
|
|
106
|
+
"launch entry argv must be a non-empty list of strings -- a single "
|
|
107
|
+
"string would invite shell interpretation, and the launcher never "
|
|
108
|
+
"hands a plan's contents to a shell"
|
|
109
|
+
)
|
|
110
|
+
keys = data.get("env_allowlist_keys", [])
|
|
111
|
+
if not isinstance(keys, (list, tuple)) or not all(isinstance(k, str) for k in keys):
|
|
112
|
+
raise LaunchExecutionError("env_allowlist_keys must be a list of strings")
|
|
113
|
+
return cls(
|
|
114
|
+
unit_key=data["unit_key"],
|
|
115
|
+
workdir=data["workdir"],
|
|
116
|
+
argv=tuple(argv),
|
|
117
|
+
env_allowlist_keys=tuple(keys),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _require_exact_env(entry: LaunchEntry, values: Mapping[str, str]) -> dict[str, str]:
|
|
122
|
+
"""The allowlist is exact in BOTH directions.
|
|
123
|
+
|
|
124
|
+
Extra: the caller supplied a value for a key the plan never declared. The plan
|
|
125
|
+
is what a reviewer reads to know what a process receives, so a value outside
|
|
126
|
+
it is invisible to review.
|
|
127
|
+
|
|
128
|
+
Missing: the plan declared a key the launch has no value for. Starting the
|
|
129
|
+
process anyway hands the agent a half-built environment and the failure
|
|
130
|
+
appears later as behaviour nobody traces back to launch.
|
|
131
|
+
|
|
132
|
+
Both are refusals, and they are checked separately because a single
|
|
133
|
+
set-equality assertion would report the wrong one first and teach the
|
|
134
|
+
operator the wrong thing to fix."""
|
|
135
|
+
declared = set(entry.env_allowlist_keys)
|
|
136
|
+
supplied = set(values)
|
|
137
|
+
|
|
138
|
+
extra = sorted(supplied - declared)
|
|
139
|
+
if extra:
|
|
140
|
+
raise LaunchExecutionError(
|
|
141
|
+
f"unit {entry.unit_key}: environment value(s) supplied for undeclared "
|
|
142
|
+
f"key(s) {extra} -- the launch plan is what a reviewer reads to know what "
|
|
143
|
+
"a process receives, so anything outside it is invisible to review"
|
|
144
|
+
)
|
|
145
|
+
missing = sorted(declared - supplied)
|
|
146
|
+
if missing:
|
|
147
|
+
raise LaunchExecutionError(
|
|
148
|
+
f"unit {entry.unit_key}: no value supplied for declared key(s) {missing} -- "
|
|
149
|
+
"starting with a half-built environment defers the failure to runtime"
|
|
150
|
+
)
|
|
151
|
+
return {k: str(values[k]) for k in entry.env_allowlist_keys}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Launcher-owned process mechanics, supplied EXPLICITLY rather than inherited.
|
|
155
|
+
# §6 classes these as launcher-owned precisely so a plan cannot declare them --
|
|
156
|
+
# but the child still needs them to run at all (a binary resolved by name needs
|
|
157
|
+
# PATH). Naming them here is the difference between "the launcher provides these,
|
|
158
|
+
# for these reasons" and "whatever the parent happened to have".
|
|
159
|
+
_LAUNCHER_OWNED_PASSTHROUGH = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SystemRoot")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _child_environment(entry: LaunchEntry, values: Mapping[str, str]) -> dict[str, str]:
|
|
163
|
+
"""The child's COMPLETE environment, built rather than inherited.
|
|
164
|
+
|
|
165
|
+
Sentinel, #825: passing `{**os.environ, **declared}` inherits the parent's
|
|
166
|
+
entire environment, so a child sees ambient variables that were never declared
|
|
167
|
+
anywhere -- reproduced on a live host, where variables belonging to the
|
|
168
|
+
launching process reached a spawned child.
|
|
169
|
+
|
|
170
|
+
My allowlist check was exact in two directions and both were about the
|
|
171
|
+
SUPPLIED dict: a value for an undeclared key, and a declared key with no
|
|
172
|
+
value. Neither asks what the child ACTUALLY ENDS UP WITH. Receiving the
|
|
173
|
+
right thing and receiving ONLY that are different claims, and the test that
|
|
174
|
+
asserted the child got the declared value could not see the difference.
|
|
175
|
+
|
|
176
|
+
So the environment is CONSTRUCTED: exactly the declared keys, plus a named
|
|
177
|
+
set of launcher-owned mechanics the child cannot run without. Nothing is
|
|
178
|
+
inherited implicitly. Everything present is there because something declared
|
|
179
|
+
it or because this list names it.
|
|
180
|
+
|
|
181
|
+
That matters beyond tidiness: a caller may pass values it does not want
|
|
182
|
+
written anywhere, and inheriting os.environ would put the launching
|
|
183
|
+
process's entire environment into every child regardless."""
|
|
184
|
+
child = _require_exact_env(entry, values)
|
|
185
|
+
for key in _LAUNCHER_OWNED_PASSTHROUGH:
|
|
186
|
+
ambient = os.environ.get(key)
|
|
187
|
+
if ambient is not None and key not in child:
|
|
188
|
+
child[key] = ambient
|
|
189
|
+
return child
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclasses.dataclass(frozen=True)
|
|
193
|
+
class TmuxPaneHandle:
|
|
194
|
+
"""A handle returned by the runtime, never a name to resolve later.
|
|
195
|
+
|
|
196
|
+
``alive_at_launch`` is deliberately a momentary observation. A field named
|
|
197
|
+
``alive`` would read as current truth even though the process can exit the
|
|
198
|
+
instant after this value is returned.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
kind: str
|
|
202
|
+
unit_key: str
|
|
203
|
+
socket_path: Path
|
|
204
|
+
session_id: str
|
|
205
|
+
server_pid: int
|
|
206
|
+
pane_id: str
|
|
207
|
+
pid: int
|
|
208
|
+
workdir: Path
|
|
209
|
+
env_keys: tuple[str, ...]
|
|
210
|
+
alive_at_launch: bool
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class LaunchRuntime(Protocol):
|
|
214
|
+
"""Common interface implemented by the available launch modes."""
|
|
215
|
+
|
|
216
|
+
def launch_team(
|
|
217
|
+
self,
|
|
218
|
+
entries: Sequence[LaunchEntry],
|
|
219
|
+
*,
|
|
220
|
+
workspace_root: Path,
|
|
221
|
+
env_values_by_unit: Mapping[str, Mapping[str, str]],
|
|
222
|
+
settle_seconds: float,
|
|
223
|
+
) -> list[dict[str, object]] | list[TmuxPaneHandle]: ...
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclasses.dataclass(frozen=True)
|
|
227
|
+
class DirectProcessRuntime:
|
|
228
|
+
"""Today's headless process runtime, retained as the default behavior."""
|
|
229
|
+
|
|
230
|
+
def launch_team(
|
|
231
|
+
self,
|
|
232
|
+
entries: Sequence[LaunchEntry],
|
|
233
|
+
*,
|
|
234
|
+
workspace_root: Path,
|
|
235
|
+
env_values_by_unit: Mapping[str, Mapping[str, str]],
|
|
236
|
+
settle_seconds: float,
|
|
237
|
+
) -> list[dict[str, object]]:
|
|
238
|
+
return _launch_team_direct(
|
|
239
|
+
entries,
|
|
240
|
+
workspace_root=workspace_root,
|
|
241
|
+
env_values_by_unit=env_values_by_unit,
|
|
242
|
+
settle_seconds=settle_seconds,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _tmux_client_environment() -> dict[str, str]:
|
|
247
|
+
"""Build the tmux client's complete environment from a closed allowlist.
|
|
248
|
+
|
|
249
|
+
The server inherits the environment of the client that creates it. An
|
|
250
|
+
inherited ``TMUX`` can also redirect a bare client to an ambient server.
|
|
251
|
+
Building rather than filtering makes both properties closed by
|
|
252
|
+
construction: unknown selector variables and unrelated ambient values do
|
|
253
|
+
not cross merely because nobody remembered to add them to a denylist.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
allowed = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR")
|
|
257
|
+
env = {key: os.environ[key] for key in allowed if key in os.environ}
|
|
258
|
+
env.pop("TMUX", None)
|
|
259
|
+
env.pop("TMUX_PANE", None)
|
|
260
|
+
return env
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _parse_tmux_version(output: str) -> tuple[int, int] | None:
|
|
264
|
+
match = re.fullmatch(r"tmux\s+(\d+)\.(\d+)[a-z]?", output.strip())
|
|
265
|
+
if match is None:
|
|
266
|
+
return None
|
|
267
|
+
return int(match.group(1)), int(match.group(2))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _process_is_alive(pid: int) -> bool:
|
|
271
|
+
try:
|
|
272
|
+
os.kill(pid, 0)
|
|
273
|
+
except ProcessLookupError:
|
|
274
|
+
return False
|
|
275
|
+
except PermissionError:
|
|
276
|
+
return True
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclasses.dataclass(frozen=True)
|
|
281
|
+
class _PreparedTmuxEntry:
|
|
282
|
+
entry: LaunchEntry
|
|
283
|
+
workdir: Path
|
|
284
|
+
env: Mapping[str, str]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclasses.dataclass(frozen=True)
|
|
288
|
+
class _ObservedPane:
|
|
289
|
+
pane_id: str
|
|
290
|
+
pid: int
|
|
291
|
+
workdir: Path
|
|
292
|
+
dead: bool
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclasses.dataclass(frozen=True)
|
|
296
|
+
class TmuxPaneRuntime:
|
|
297
|
+
"""Launch one unit per pane on an explicit, create-only tmux server.
|
|
298
|
+
|
|
299
|
+
The socket and session are caller inputs. The runtime never derives either
|
|
300
|
+
coordinate, uses tmux's default server, attaches, or reuses a socket. Every
|
|
301
|
+
invocation is structurally prefixed by ``-S``. After session creation, the
|
|
302
|
+
runtime addresses only tmux IDs.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
socket_path: Path | str
|
|
306
|
+
session_name: str
|
|
307
|
+
tmux_binary: str = "tmux"
|
|
308
|
+
|
|
309
|
+
def __post_init__(self) -> None:
|
|
310
|
+
raw_socket = os.fspath(self.socket_path)
|
|
311
|
+
if not raw_socket or not isinstance(self.session_name, str) or not self.session_name:
|
|
312
|
+
raise LaunchExecutionError(
|
|
313
|
+
"tmux socket path and session name are required explicit inputs"
|
|
314
|
+
)
|
|
315
|
+
if not isinstance(self.tmux_binary, str) or not self.tmux_binary:
|
|
316
|
+
raise LaunchExecutionError("tmux binary is required")
|
|
317
|
+
|
|
318
|
+
socket_path = Path(raw_socket)
|
|
319
|
+
object.__setattr__(self, "socket_path", socket_path)
|
|
320
|
+
self._validate_socket_coordinate()
|
|
321
|
+
|
|
322
|
+
def _validate_socket_coordinate(self) -> None:
|
|
323
|
+
socket_path = Path(self.socket_path)
|
|
324
|
+
measured = len(os.fsencode(socket_path))
|
|
325
|
+
if measured > _AF_UNIX_SOCKET_PATH_MAX_BYTES:
|
|
326
|
+
raise LaunchExecutionError(
|
|
327
|
+
f"tmux socket path is {measured} encoded bytes, exceeding the "
|
|
328
|
+
f"portable AF_UNIX limit of {_AF_UNIX_SOCKET_PATH_MAX_BYTES}"
|
|
329
|
+
)
|
|
330
|
+
if not socket_path.is_absolute():
|
|
331
|
+
raise LaunchExecutionError("tmux socket path must be absolute")
|
|
332
|
+
|
|
333
|
+
parent = socket_path.parent
|
|
334
|
+
if parent.is_symlink():
|
|
335
|
+
raise LaunchExecutionError("tmux socket parent must not be a symlink")
|
|
336
|
+
try:
|
|
337
|
+
parent_stat = parent.stat()
|
|
338
|
+
except OSError as exc:
|
|
339
|
+
raise LaunchExecutionError(
|
|
340
|
+
f"tmux socket parent must already exist as a private 0700 directory: {exc}"
|
|
341
|
+
) from exc
|
|
342
|
+
if not stat.S_ISDIR(parent_stat.st_mode):
|
|
343
|
+
raise LaunchExecutionError("tmux socket parent must be a directory")
|
|
344
|
+
mode = stat.S_IMODE(parent_stat.st_mode)
|
|
345
|
+
if mode != 0o700:
|
|
346
|
+
raise LaunchExecutionError(f"tmux socket parent must have mode 0700, found {mode:04o}")
|
|
347
|
+
if hasattr(os, "getuid") and parent_stat.st_uid != os.getuid():
|
|
348
|
+
raise LaunchExecutionError("tmux socket parent must be owned by the current user")
|
|
349
|
+
|
|
350
|
+
def _invoke(self, *args: str, operation: str) -> subprocess.CompletedProcess[str]:
|
|
351
|
+
command = [self.tmux_binary, "-S", str(self.socket_path), *args]
|
|
352
|
+
try:
|
|
353
|
+
return subprocess.run( # noqa: S603
|
|
354
|
+
command,
|
|
355
|
+
env=_tmux_client_environment(),
|
|
356
|
+
capture_output=True,
|
|
357
|
+
text=True,
|
|
358
|
+
check=False,
|
|
359
|
+
)
|
|
360
|
+
except FileNotFoundError as exc:
|
|
361
|
+
raise LaunchExecutionError(
|
|
362
|
+
f"tmux executable {self.tmux_binary!r} was not found"
|
|
363
|
+
) from exc
|
|
364
|
+
except OSError as exc:
|
|
365
|
+
raise LaunchExecutionError(f"tmux {operation} could not run: {exc}") from exc
|
|
366
|
+
|
|
367
|
+
def _require_success(self, result: subprocess.CompletedProcess[str], *, operation: str) -> str:
|
|
368
|
+
if result.returncode != 0:
|
|
369
|
+
raise LaunchExecutionError(
|
|
370
|
+
f"tmux {operation} failed with exit code {result.returncode}"
|
|
371
|
+
)
|
|
372
|
+
return result.stdout.strip()
|
|
373
|
+
|
|
374
|
+
def _require_supported_version(self) -> None:
|
|
375
|
+
result = self._invoke("-V", operation="version check")
|
|
376
|
+
reported = (result.stdout or result.stderr).strip()
|
|
377
|
+
version = _parse_tmux_version(reported) if result.returncode == 0 else None
|
|
378
|
+
if version is None or version < _MINIMUM_TMUX_VERSION:
|
|
379
|
+
shown = reported or f"exit code {result.returncode}"
|
|
380
|
+
raise LaunchExecutionError(
|
|
381
|
+
f"tmux {shown!r} is unsupported; tmux 3.2 or newer is required "
|
|
382
|
+
"for per-pane -e environment isolation"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def _prepare(
|
|
386
|
+
self,
|
|
387
|
+
entries: Sequence[LaunchEntry],
|
|
388
|
+
*,
|
|
389
|
+
workspace_root: Path,
|
|
390
|
+
env_values_by_unit: Mapping[str, Mapping[str, str]],
|
|
391
|
+
) -> list[_PreparedTmuxEntry]:
|
|
392
|
+
prepared = []
|
|
393
|
+
for entry in entries:
|
|
394
|
+
values = env_values_by_unit.get(entry.unit_key)
|
|
395
|
+
if values is None:
|
|
396
|
+
raise LaunchExecutionError(f"no environment supplied for unit {entry.unit_key}")
|
|
397
|
+
workdir = canonicalize_workspace_path(
|
|
398
|
+
workspace_root,
|
|
399
|
+
entry.workdir,
|
|
400
|
+
field_name=f"launch[{entry.unit_key}].workdir",
|
|
401
|
+
)
|
|
402
|
+
if not workdir.is_dir():
|
|
403
|
+
raise LaunchExecutionError(
|
|
404
|
+
f"unit {entry.unit_key}: workdir {entry.workdir} does not exist -- "
|
|
405
|
+
"materialization runs before launch"
|
|
406
|
+
)
|
|
407
|
+
prepared.append(
|
|
408
|
+
_PreparedTmuxEntry(
|
|
409
|
+
entry=entry,
|
|
410
|
+
workdir=workdir,
|
|
411
|
+
env=_child_environment(entry, values),
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
return prepared
|
|
415
|
+
|
|
416
|
+
def _create_session(self, workspace_root: Path) -> str:
|
|
417
|
+
result = self._invoke(
|
|
418
|
+
"new-session",
|
|
419
|
+
"-d",
|
|
420
|
+
"-P",
|
|
421
|
+
"-F",
|
|
422
|
+
"#{pid}\t#{session_id}\t#{pane_id}",
|
|
423
|
+
"-s",
|
|
424
|
+
self.session_name,
|
|
425
|
+
"-c",
|
|
426
|
+
str(workspace_root),
|
|
427
|
+
"--",
|
|
428
|
+
sys.executable,
|
|
429
|
+
"-c",
|
|
430
|
+
"import time; time.sleep(86400)",
|
|
431
|
+
operation="session creation",
|
|
432
|
+
)
|
|
433
|
+
return self._require_success(result, operation="session creation")
|
|
434
|
+
|
|
435
|
+
def _parse_session_handles(self, output: str) -> tuple[int, str, str]:
|
|
436
|
+
fields = output.split("\t")
|
|
437
|
+
if len(fields) != 3 or not fields[1].startswith("$") or not fields[2].startswith("%"):
|
|
438
|
+
raise LaunchExecutionError("tmux session creation returned malformed handle evidence")
|
|
439
|
+
try:
|
|
440
|
+
server_pid = int(fields[0])
|
|
441
|
+
except ValueError as exc:
|
|
442
|
+
raise LaunchExecutionError(
|
|
443
|
+
"tmux session creation returned a non-numeric server PID"
|
|
444
|
+
) from exc
|
|
445
|
+
if server_pid <= 0:
|
|
446
|
+
raise LaunchExecutionError("tmux session creation returned an invalid server PID")
|
|
447
|
+
return server_pid, fields[1], fields[2]
|
|
448
|
+
|
|
449
|
+
def _create_unit_pane(self, prepared: _PreparedTmuxEntry, *, session_id: str) -> str:
|
|
450
|
+
environment_args: list[str] = []
|
|
451
|
+
for key, value in prepared.env.items():
|
|
452
|
+
environment_args.extend(("-e", f"{key}={value}"))
|
|
453
|
+
result = self._invoke(
|
|
454
|
+
"new-window",
|
|
455
|
+
"-d",
|
|
456
|
+
"-P",
|
|
457
|
+
"-F",
|
|
458
|
+
"#{pane_id}",
|
|
459
|
+
"-t",
|
|
460
|
+
session_id,
|
|
461
|
+
"-c",
|
|
462
|
+
str(prepared.workdir),
|
|
463
|
+
*environment_args,
|
|
464
|
+
"--",
|
|
465
|
+
*prepared.entry.argv,
|
|
466
|
+
operation=f"unit {prepared.entry.unit_key} pane creation",
|
|
467
|
+
)
|
|
468
|
+
pane_id = self._require_success(
|
|
469
|
+
result, operation=f"unit {prepared.entry.unit_key} pane creation"
|
|
470
|
+
)
|
|
471
|
+
if not pane_id.startswith("%") or "\n" in pane_id:
|
|
472
|
+
raise LaunchExecutionError(
|
|
473
|
+
f"unit {prepared.entry.unit_key}: tmux returned malformed pane handle evidence"
|
|
474
|
+
)
|
|
475
|
+
return pane_id
|
|
476
|
+
|
|
477
|
+
def _observe_panes(self) -> dict[str, _ObservedPane]:
|
|
478
|
+
result = self._invoke(
|
|
479
|
+
"list-panes",
|
|
480
|
+
"-a",
|
|
481
|
+
"-F",
|
|
482
|
+
"#{pane_id}\t#{pane_pid}\t#{pane_current_path}\t#{pane_dead}",
|
|
483
|
+
operation="pane observation",
|
|
484
|
+
)
|
|
485
|
+
output = self._require_success(result, operation="pane observation")
|
|
486
|
+
observed: dict[str, _ObservedPane] = {}
|
|
487
|
+
for line in output.splitlines():
|
|
488
|
+
fields = line.split("\t")
|
|
489
|
+
if len(fields) != 4:
|
|
490
|
+
raise LaunchExecutionError("tmux returned malformed pane observation evidence")
|
|
491
|
+
pane_id, pid_text, workdir_text, dead_text = fields
|
|
492
|
+
try:
|
|
493
|
+
pid = int(pid_text)
|
|
494
|
+
except ValueError as exc:
|
|
495
|
+
raise LaunchExecutionError("tmux returned a non-numeric pane PID") from exc
|
|
496
|
+
observed[pane_id] = _ObservedPane(
|
|
497
|
+
pane_id=pane_id,
|
|
498
|
+
pid=pid,
|
|
499
|
+
workdir=Path(workdir_text),
|
|
500
|
+
dead=dead_text != "0",
|
|
501
|
+
)
|
|
502
|
+
return observed
|
|
503
|
+
|
|
504
|
+
def _require_live_units(
|
|
505
|
+
self,
|
|
506
|
+
pane_ids: Mapping[str, str],
|
|
507
|
+
prepared_by_unit: Mapping[str, _PreparedTmuxEntry],
|
|
508
|
+
) -> dict[str, _ObservedPane]:
|
|
509
|
+
observed = self._observe_panes()
|
|
510
|
+
live: dict[str, _ObservedPane] = {}
|
|
511
|
+
for unit_key, pane_id in pane_ids.items():
|
|
512
|
+
pane = observed.get(pane_id)
|
|
513
|
+
if pane is None or pane.dead:
|
|
514
|
+
raise LaunchExecutionError(
|
|
515
|
+
f"unit {unit_key}: pane was not alive at launch observation"
|
|
516
|
+
)
|
|
517
|
+
expected_workdir = prepared_by_unit[unit_key].workdir.resolve()
|
|
518
|
+
if pane.workdir.resolve() != expected_workdir:
|
|
519
|
+
raise LaunchExecutionError(
|
|
520
|
+
f"unit {unit_key}: pane started in an unexpected working directory"
|
|
521
|
+
)
|
|
522
|
+
live[unit_key] = pane
|
|
523
|
+
return live
|
|
524
|
+
|
|
525
|
+
def _socket_listener_is_reachable(self) -> bool:
|
|
526
|
+
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
527
|
+
try:
|
|
528
|
+
probe.connect(os.fspath(self.socket_path))
|
|
529
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
530
|
+
return False
|
|
531
|
+
except OSError as exc:
|
|
532
|
+
raise LaunchExecutionError(
|
|
533
|
+
f"tmux rollback termination could not be observed safely: {exc}"
|
|
534
|
+
) from exc
|
|
535
|
+
else:
|
|
536
|
+
return True
|
|
537
|
+
finally:
|
|
538
|
+
probe.close()
|
|
539
|
+
|
|
540
|
+
def _kill_created_server(
|
|
541
|
+
self,
|
|
542
|
+
socket_identity: tuple[int, int] | None,
|
|
543
|
+
server_pid: int | None,
|
|
544
|
+
) -> None:
|
|
545
|
+
result = self._invoke("kill-server", operation="rollback")
|
|
546
|
+
self._require_success(result, operation="rollback")
|
|
547
|
+
if server_pid is None:
|
|
548
|
+
raise LaunchExecutionError(
|
|
549
|
+
"tmux rollback cannot verify server-process death without captured PID evidence"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Verify both fruits independently of the tmux client whose
|
|
553
|
+
# acknowledgement is under test. Calling that same executable a second
|
|
554
|
+
# time would be one stance twice: a wrapper could falsely report both
|
|
555
|
+
# successful termination and failed lookup. Listener death and process
|
|
556
|
+
# death are separate too: an orphan can close or lose its socket while
|
|
557
|
+
# remaining alive.
|
|
558
|
+
deadline = time.monotonic() + _TMUX_ROLLBACK_SETTLE_SECONDS
|
|
559
|
+
while True:
|
|
560
|
+
listener_reachable = self._socket_listener_is_reachable()
|
|
561
|
+
process_alive = _process_is_alive(server_pid)
|
|
562
|
+
if not listener_reachable and not process_alive:
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
if time.monotonic() >= deadline:
|
|
566
|
+
survivors = []
|
|
567
|
+
if listener_reachable:
|
|
568
|
+
survivors.append("socket listener is still reachable")
|
|
569
|
+
if process_alive:
|
|
570
|
+
survivors.append(f"server process {server_pid} is still alive")
|
|
571
|
+
raise LaunchExecutionError(
|
|
572
|
+
"tmux rollback was acknowledged but " + " and ".join(survivors)
|
|
573
|
+
)
|
|
574
|
+
time.sleep(_TMUX_ROLLBACK_POLL_SECONDS)
|
|
575
|
+
|
|
576
|
+
# tmux can leave the AF_UNIX node behind after the server has exited.
|
|
577
|
+
# Remove only the exact socket this call observed creating. If another
|
|
578
|
+
# process replaced the path, inode identity no longer matches and this
|
|
579
|
+
# rollback must not spend ownership it does not have.
|
|
580
|
+
if socket_identity is None:
|
|
581
|
+
return
|
|
582
|
+
try:
|
|
583
|
+
current = Path(self.socket_path).lstat()
|
|
584
|
+
except FileNotFoundError:
|
|
585
|
+
return
|
|
586
|
+
if stat.S_ISSOCK(current.st_mode) and (current.st_dev, current.st_ino) == socket_identity:
|
|
587
|
+
Path(self.socket_path).unlink()
|
|
588
|
+
|
|
589
|
+
def launch_team(
|
|
590
|
+
self,
|
|
591
|
+
entries: Sequence[LaunchEntry],
|
|
592
|
+
*,
|
|
593
|
+
workspace_root: Path,
|
|
594
|
+
env_values_by_unit: Mapping[str, Mapping[str, str]],
|
|
595
|
+
settle_seconds: float,
|
|
596
|
+
) -> list[TmuxPaneHandle]:
|
|
597
|
+
entries = tuple(entries)
|
|
598
|
+
if not entries:
|
|
599
|
+
return []
|
|
600
|
+
|
|
601
|
+
workspace_root = Path(os.fspath(workspace_root))
|
|
602
|
+
prepared = self._prepare(
|
|
603
|
+
entries,
|
|
604
|
+
workspace_root=workspace_root,
|
|
605
|
+
env_values_by_unit=env_values_by_unit,
|
|
606
|
+
)
|
|
607
|
+
prepared_by_unit = {item.entry.unit_key: item for item in prepared}
|
|
608
|
+
self._validate_socket_coordinate()
|
|
609
|
+
self._require_supported_version()
|
|
610
|
+
if os.path.lexists(self.socket_path):
|
|
611
|
+
raise LaunchExecutionError(
|
|
612
|
+
f"tmux socket {self.socket_path} already exists; launch is create-only"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
server_created = False
|
|
616
|
+
created_socket_identity: tuple[int, int] | None = None
|
|
617
|
+
server_pid: int | None = None
|
|
618
|
+
try:
|
|
619
|
+
session_output = self._create_session(workspace_root)
|
|
620
|
+
server_created = True
|
|
621
|
+
server_pid, session_id, bootstrap_pane = self._parse_session_handles(session_output)
|
|
622
|
+
|
|
623
|
+
socket_stat = Path(self.socket_path).lstat()
|
|
624
|
+
if not stat.S_ISSOCK(socket_stat.st_mode):
|
|
625
|
+
raise LaunchExecutionError(
|
|
626
|
+
"tmux did not create a Unix socket at the requested path"
|
|
627
|
+
)
|
|
628
|
+
created_socket_identity = (socket_stat.st_dev, socket_stat.st_ino)
|
|
629
|
+
Path(self.socket_path).chmod(0o600)
|
|
630
|
+
|
|
631
|
+
pane_ids = {
|
|
632
|
+
item.entry.unit_key: self._create_unit_pane(item, session_id=session_id)
|
|
633
|
+
for item in prepared
|
|
634
|
+
}
|
|
635
|
+
time.sleep(settle_seconds)
|
|
636
|
+
self._require_live_units(pane_ids, prepared_by_unit)
|
|
637
|
+
|
|
638
|
+
# The bootstrap is removed only while unit panes exist. Then every
|
|
639
|
+
# unit is observed again, because the first observation is stale the
|
|
640
|
+
# instant cleanup begins and a dead unit must not return success.
|
|
641
|
+
cleanup = self._invoke("kill-pane", "-t", bootstrap_pane, operation="bootstrap removal")
|
|
642
|
+
self._require_success(cleanup, operation="bootstrap removal")
|
|
643
|
+
live = self._require_live_units(pane_ids, prepared_by_unit)
|
|
644
|
+
|
|
645
|
+
return [
|
|
646
|
+
TmuxPaneHandle(
|
|
647
|
+
kind="tmux-pane",
|
|
648
|
+
unit_key=item.entry.unit_key,
|
|
649
|
+
socket_path=Path(self.socket_path),
|
|
650
|
+
session_id=session_id,
|
|
651
|
+
server_pid=server_pid,
|
|
652
|
+
pane_id=pane_ids[item.entry.unit_key],
|
|
653
|
+
pid=live[item.entry.unit_key].pid,
|
|
654
|
+
workdir=live[item.entry.unit_key].workdir,
|
|
655
|
+
env_keys=item.entry.env_allowlist_keys,
|
|
656
|
+
alive_at_launch=True,
|
|
657
|
+
)
|
|
658
|
+
for item in prepared
|
|
659
|
+
]
|
|
660
|
+
except BaseException:
|
|
661
|
+
if server_created:
|
|
662
|
+
self._kill_created_server(created_socket_identity, server_pid)
|
|
663
|
+
raise
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def launch_unit(
|
|
667
|
+
entry: LaunchEntry,
|
|
668
|
+
*,
|
|
669
|
+
workspace_root: Path,
|
|
670
|
+
env_values: Mapping[str, str],
|
|
671
|
+
settle_seconds: float = _LIVENESS_SETTLE_SECONDS,
|
|
672
|
+
) -> dict[str, object]:
|
|
673
|
+
"""Start one unit's process and prove it is alive.
|
|
674
|
+
|
|
675
|
+
`env_values` is consumed in memory and never written anywhere. Returns
|
|
676
|
+
neutral evidence -- no argv values, no env, nothing identity-bearing."""
|
|
677
|
+
workspace_root = Path(os.fspath(workspace_root))
|
|
678
|
+
workdir = canonicalize_workspace_path(
|
|
679
|
+
workspace_root, entry.workdir, field_name=f"launch[{entry.unit_key}].workdir"
|
|
680
|
+
)
|
|
681
|
+
if not workdir.is_dir():
|
|
682
|
+
raise LaunchExecutionError(
|
|
683
|
+
f"unit {entry.unit_key}: workdir {entry.workdir} does not exist -- "
|
|
684
|
+
"materialization runs before launch, so a missing workspace means the "
|
|
685
|
+
"unit was never materialized rather than that launch should create it"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
env = _child_environment(entry, env_values)
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
# No shell. argv is a list, and a plan's contents are never handed to a
|
|
692
|
+
# shell for interpretation.
|
|
693
|
+
proc = subprocess.Popen( # noqa: S603
|
|
694
|
+
list(entry.argv),
|
|
695
|
+
cwd=str(workdir),
|
|
696
|
+
env=env,
|
|
697
|
+
stdout=subprocess.DEVNULL,
|
|
698
|
+
stderr=subprocess.DEVNULL,
|
|
699
|
+
start_new_session=True,
|
|
700
|
+
)
|
|
701
|
+
except FileNotFoundError as exc:
|
|
702
|
+
raise LaunchExecutionError(
|
|
703
|
+
f"unit {entry.unit_key}: {entry.argv[0]!r} not found on PATH"
|
|
704
|
+
) from exc
|
|
705
|
+
except OSError as exc:
|
|
706
|
+
raise LaunchExecutionError(f"unit {entry.unit_key}: launch failed: {exc}") from exc
|
|
707
|
+
|
|
708
|
+
# LIVENESS, not spawn-return. A process that exits immediately produces the
|
|
709
|
+
# same successful Popen as one that came up healthy.
|
|
710
|
+
time.sleep(settle_seconds)
|
|
711
|
+
code = proc.poll()
|
|
712
|
+
if code is not None:
|
|
713
|
+
raise LaunchExecutionError(
|
|
714
|
+
f"unit {entry.unit_key}: process exited with code {code} within "
|
|
715
|
+
f"{settle_seconds}s of launch -- a spawn that returns is not an agent "
|
|
716
|
+
"that runs, and an immediately-dead process is indistinguishable from a "
|
|
717
|
+
"healthy one until someone looks"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
"kind": "launch",
|
|
722
|
+
"unit_key": entry.unit_key,
|
|
723
|
+
"workdir": entry.workdir,
|
|
724
|
+
"pid": proc.pid,
|
|
725
|
+
"env_keys": list(entry.env_allowlist_keys), # NAMES only, never values
|
|
726
|
+
"alive": True,
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _launch_team_direct(
|
|
731
|
+
entries: Sequence[LaunchEntry],
|
|
732
|
+
*,
|
|
733
|
+
workspace_root: Path,
|
|
734
|
+
env_values_by_unit: Mapping[str, Mapping[str, str]],
|
|
735
|
+
settle_seconds: float = _LIVENESS_SETTLE_SECONDS,
|
|
736
|
+
) -> list[dict[str, object]]:
|
|
737
|
+
"""Launch every unit, or none of them.
|
|
738
|
+
|
|
739
|
+
A partially launched team is the same failure shape as a partially
|
|
740
|
+
materialized one: some agents alive, some absent, presenting as a confused
|
|
741
|
+
team rather than an error. Anything already started is terminated before the
|
|
742
|
+
failure propagates, so a retry does not race a half-live team."""
|
|
743
|
+
started: list[tuple[dict[str, object], int]] = []
|
|
744
|
+
try:
|
|
745
|
+
for entry in entries:
|
|
746
|
+
values = env_values_by_unit.get(entry.unit_key)
|
|
747
|
+
if values is None:
|
|
748
|
+
raise LaunchExecutionError(f"no environment supplied for unit {entry.unit_key}")
|
|
749
|
+
evidence = launch_unit(
|
|
750
|
+
entry,
|
|
751
|
+
workspace_root=workspace_root,
|
|
752
|
+
env_values=values,
|
|
753
|
+
settle_seconds=settle_seconds,
|
|
754
|
+
)
|
|
755
|
+
started.append((evidence, int(evidence["pid"])))
|
|
756
|
+
except BaseException:
|
|
757
|
+
for _, pid in started:
|
|
758
|
+
try:
|
|
759
|
+
os.killpg(os.getpgid(pid), 15)
|
|
760
|
+
except (ProcessLookupError, PermissionError): # pragma: no cover
|
|
761
|
+
pass
|
|
762
|
+
raise
|
|
763
|
+
return [ev for ev, _ in started]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def launch_team(
|
|
767
|
+
entries: Sequence[LaunchEntry],
|
|
768
|
+
*,
|
|
769
|
+
workspace_root: Path,
|
|
770
|
+
env_values_by_unit: Mapping[str, Mapping[str, str]],
|
|
771
|
+
settle_seconds: float = _LIVENESS_SETTLE_SECONDS,
|
|
772
|
+
runtime: LaunchRuntime | None = None,
|
|
773
|
+
) -> list[dict[str, object]] | list[TmuxPaneHandle]:
|
|
774
|
+
"""Launch a team through the caller-selected runtime.
|
|
775
|
+
|
|
776
|
+
Direct processes remain the default for compatibility. An alternate
|
|
777
|
+
runtime is an explicit strategy input rather than an ambient choice.
|
|
778
|
+
"""
|
|
779
|
+
|
|
780
|
+
selected: LaunchRuntime = runtime if runtime is not None else DirectProcessRuntime()
|
|
781
|
+
return selected.launch_team(
|
|
782
|
+
entries,
|
|
783
|
+
workspace_root=Path(os.fspath(workspace_root)),
|
|
784
|
+
env_values_by_unit=env_values_by_unit,
|
|
785
|
+
settle_seconds=settle_seconds,
|
|
786
|
+
)
|