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.
Files changed (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. 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
+ )