syncmill 0.2.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 (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/tui_jobs.py ADDED
@@ -0,0 +1,1407 @@
1
+ """Durable, POSIX-only job manager used by ``syncmill tui``.
2
+
3
+ The Textual process never owns orchestration. It writes a small job spec and
4
+ starts this module as a detached manager; the manager owns the CLI child, rolling
5
+ log, authenticated control socket, exit receipt, and per-target launch lock.
6
+
7
+ Generalized in #0.1.2 Phase 3 from a board-only, single-active-job manager into a
8
+ project-scoped, multi-job manager keyed PER LOGICAL TARGET (the settled Phase-3
9
+ decisions): the store is anchored on the canonical git root (``project_key``), each
10
+ job holds its OWN launch lock (so different targets run concurrently while a
11
+ duplicate launch for the same target fails fast), and source mutation is serialized
12
+ NOT here but on the existing ``artifacts.apply_lock`` — this manager lock is
13
+ exclusion, never crash recovery. Chunk A generalizes the plumbing; board-run stays
14
+ the only op the UI launches (room/artifact-apply execution lands in Chunk B).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import selectors
24
+ import secrets
25
+ import signal
26
+ import socket
27
+ import stat
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import threading
32
+ import time
33
+ from collections.abc import Sequence
34
+ from dataclasses import asdict, dataclass, field
35
+ from pathlib import Path
36
+ from typing import Any, NoReturn
37
+ from uuid import uuid4
38
+
39
+ from syncmill.config import SyncmillConfig, TuiConfig
40
+
41
+ try: # browsing remains available on non-POSIX platforms; execution is gated.
42
+ import fcntl
43
+ except ImportError: # pragma: no cover - exercised on Windows
44
+ fcntl = None # type: ignore[assignment]
45
+
46
+ #: Bumped to 2 when the manager became project-scoped/multi-job. Legacy schema-1
47
+ #: board job dirs are still READ (never written) via the compat overlay below.
48
+ _SCHEMA_VERSION = 2
49
+ _STOP_GRACE_SECONDS = 10.0
50
+ #: A force stop (SIGKILL) is only honored this long after a safe stop (SIGINT).
51
+ _FORCE_GRACE_SECONDS = 10.0
52
+ _TERMINAL = {"finished", "cancelled", "force_stopped", "launch_failed", "lost"}
53
+ #: The manager writes this one byte to the guardian pipe AFTER it has durably
54
+ #: recorded a terminal state — the "clean completion, you may stand down" ack.
55
+ #: A manager pipe that reaches EOF WITHOUT it means the manager died mid-run, so
56
+ #: the guardian must tear the child's process group down.
57
+ _MANAGER_DONE = b"\x01"
58
+
59
+
60
+ class JobError(RuntimeError):
61
+ pass
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class BoardRunSpec:
66
+ """A ``board run`` operation (loop/selected). ``op`` is a fixed, ``init=False``
67
+ discriminator so today's callers construct it exactly as before."""
68
+
69
+ mode: str
70
+ item_id: str | None = None
71
+ goal: str | None = None
72
+ strategy: str | None = None
73
+ agent: str | None = None
74
+ team: str | None = None
75
+ op: str = field(default="board-run", init=False)
76
+
77
+ def __post_init__(self) -> None:
78
+ if self.mode not in {"selected", "loop"}:
79
+ raise ValueError("job mode must be 'selected' or 'loop'")
80
+ if self.mode == "selected":
81
+ if not self.item_id:
82
+ raise ValueError("selected job requires item_id")
83
+ if self.goal:
84
+ raise ValueError("selected job cannot set goal")
85
+ elif self.item_id:
86
+ raise ValueError("loop job cannot set item_id")
87
+
88
+
89
+ #: Backwards-compatible alias — the board-run spec was the only spec in schema-1.
90
+ BoardJobSpec = BoardRunSpec
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ArtifactApplySpec:
95
+ """A ``syncmill apply <run_id>`` operation run by a durable detached worker.
96
+ The store owns the repository boundary — there is no ``repo`` field."""
97
+
98
+ run_id: str
99
+ verify: bool = False
100
+ op: str = field(default="artifact-apply", init=False)
101
+
102
+ def __post_init__(self) -> None:
103
+ if not self.run_id:
104
+ raise ValueError("artifact apply requires a run id")
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class RoomApplySpec:
109
+ """A ``room apply <room_id>`` operation run by a durable detached worker,
110
+ fenced to the exact candidate/revision captured at confirmation time."""
111
+
112
+ room_id: str
113
+ verify: bool = False
114
+ expected_candidate_id: str | None = None
115
+ expected_revision: int | None = None
116
+ op: str = field(default="room-apply", init=False)
117
+
118
+ def __post_init__(self) -> None:
119
+ if not self.room_id:
120
+ raise ValueError("room apply requires a room id")
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class WorkflowRunSpec:
125
+ """A fenced workflow run/resume owned by the durable manager."""
126
+
127
+ workflow_id: str
128
+ resume: bool = False
129
+ op: str = field(default="workflow-run", init=False)
130
+
131
+ def __post_init__(self) -> None:
132
+ if not self.workflow_id:
133
+ raise ValueError("workflow run requires a workflow id")
134
+
135
+
136
+ #: The typed job spec union.
137
+ JobSpec = BoardRunSpec | ArtifactApplySpec | RoomApplySpec | WorkflowRunSpec
138
+
139
+
140
+ def build_job_argv(
141
+ spec: JobSpec,
142
+ *,
143
+ project_root: str | Path | None = None,
144
+ extra_options: Sequence[str] = (),
145
+ ) -> list[str]:
146
+ """The CLI argv the detached child runs for a spec. Apply ops emit
147
+ ``--repo <project_root>`` because the STORE owns the repository boundary (the
148
+ worker never trusts an ambient cwd for the mutation target).
149
+
150
+ Apply ops put every option BEFORE a ``--`` separator and the positional id
151
+ last: click stops option parsing at ``--``, so an id that happens to begin
152
+ with ``-`` reaches the worker verbatim instead of being re-parsed as a flag.
153
+ ``extra_options`` (the caller's late-bound flags, e.g. the resolved-config
154
+ snapshot path) is spliced in on the option side of that separator — appending
155
+ it to the finished argv would strand it after ``--`` as a positional."""
156
+ if spec.op == "board-run":
157
+ argv = ["board", "run"]
158
+ if spec.mode == "selected":
159
+ argv += ["--id", str(spec.item_id)]
160
+ else:
161
+ argv.append("--loop")
162
+ if spec.goal:
163
+ argv += ["--goal", spec.goal]
164
+ if spec.strategy:
165
+ argv += ["--strategy", spec.strategy]
166
+ if spec.agent:
167
+ argv += ["--agent", spec.agent]
168
+ if spec.team:
169
+ argv += ["--team", spec.team]
170
+ argv += list(extra_options)
171
+ return argv
172
+ if project_root is None:
173
+ raise JobError(f"{spec.op} requires the store project root")
174
+ if spec.op == "artifact-apply":
175
+ argv = ["apply-worker", "--repo", str(project_root)]
176
+ if spec.verify:
177
+ argv.append("--verify")
178
+ argv += [*extra_options, "--", str(spec.run_id)]
179
+ return argv
180
+ if spec.op == "room-apply":
181
+ argv = ["room-apply-worker", "--repo", str(project_root)]
182
+ if spec.verify:
183
+ argv.append("--verify")
184
+ if spec.expected_candidate_id:
185
+ argv += ["--expected-candidate", str(spec.expected_candidate_id)]
186
+ if spec.expected_revision is not None:
187
+ argv += ["--expected-revision", str(spec.expected_revision)]
188
+ argv += [*extra_options, "--", str(spec.room_id)]
189
+ return argv
190
+ if spec.op == "workflow-run":
191
+ argv = ["workflow-worker", "--repo", str(project_root)]
192
+ if spec.resume:
193
+ argv.append("--resume")
194
+ argv += [*extra_options, "--", str(spec.workflow_id)]
195
+ return argv
196
+ raise JobError(f"unsupported job op: {spec.op!r}")
197
+
198
+
199
+ def canonical_board_path(db_path: str, cwd: str | Path | None = None) -> Path:
200
+ try:
201
+ path = Path(db_path).expanduser()
202
+ if not path.is_absolute():
203
+ path = Path(cwd or Path.cwd()) / path
204
+ return path.resolve()
205
+ except (OSError, RuntimeError) as exc:
206
+ raise JobError(f"cannot resolve board database path {db_path!r}: {exc}") from exc
207
+
208
+
209
+ def board_key(db_path: str | Path) -> str:
210
+ """The schema-1 board key — still used for the legacy compat root AND as the
211
+ per-target lock identity of a board run (D2: ``board-run\\0<canonical path>``)."""
212
+ return hashlib.sha256(str(Path(db_path)).encode()).hexdigest()
213
+
214
+
215
+ def project_key(project_root: str | Path) -> str:
216
+ """The store anchor — the canonical git root, NOT the user-controlled
217
+ ``project.id`` (which can collide across checkouts)."""
218
+ return hashlib.sha256(b"repo-v1\0" + str(project_root).encode()).hexdigest()
219
+
220
+
221
+ def _target_lock_key(spec: JobSpec, *, project_key: str, board_db: Path | None) -> str:
222
+ """Per-logical-target launch-lock key (the settled D2 formulas)."""
223
+ if spec.op == "board-run":
224
+ if board_db is None:
225
+ raise JobError("board run requires a board database path")
226
+ return hashlib.sha256(b"board-run\0" + str(board_db).encode()).hexdigest()
227
+ if spec.op == "artifact-apply":
228
+ ident = f"{project_key}\0{spec.run_id}"
229
+ return hashlib.sha256(b"artifact-apply\0" + ident.encode()).hexdigest()
230
+ if spec.op == "room-apply":
231
+ ident = f"{project_key}\0{spec.room_id}"
232
+ return hashlib.sha256(b"room-apply\0" + ident.encode()).hexdigest()
233
+ if spec.op == "workflow-run":
234
+ ident = f"{project_key}\0{spec.workflow_id}"
235
+ return hashlib.sha256(b"workflow-run\0" + ident.encode()).hexdigest()
236
+ raise JobError(f"unsupported job op: {spec.op!r}")
237
+
238
+
239
+ def _atomic_json(path: Path, value: dict[str, Any]) -> None:
240
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
241
+ fd, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
242
+ tmp = Path(raw)
243
+ try:
244
+ # Unix-only call, guarded for runtimes without fchmod: where fchmod
245
+ # is absent the AttributeError would escape the `JobError` boundary and
246
+ # fail every job-state write. This module has its own `__main__`, and
247
+ # `python -m syncmill.tui_jobs worker` reaches `_worker_main` without
248
+ # passing the CLI group's platform refusal — its failure path records
249
+ # `launch_failed` through this very call. `mkstemp` already creates the
250
+ # temp 0600, so absence needs no fallback; an OSError still propagates.
251
+ fchmod = getattr(os, "fchmod", None)
252
+ if fchmod is not None:
253
+ fchmod(fd, 0o600)
254
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
255
+ json.dump(value, fh, ensure_ascii=False, sort_keys=True)
256
+ fh.write("\n")
257
+ fh.flush()
258
+ os.fsync(fh.fileno())
259
+ os.replace(tmp, path)
260
+ finally:
261
+ try:
262
+ tmp.unlink()
263
+ except FileNotFoundError:
264
+ pass
265
+
266
+
267
+ def _read_json(path: Path) -> dict[str, Any] | None:
268
+ try:
269
+ value = json.loads(path.read_text(encoding="utf-8"))
270
+ except (OSError, json.JSONDecodeError):
271
+ return None
272
+ return value if isinstance(value, dict) else None
273
+
274
+
275
+ def _created_at(row: dict[str, Any]) -> float:
276
+ """Sort key over ``created_at``, which is a float timestamp.
277
+
278
+ A row missing it (a foreign directory, a half-written spec) must not make the
279
+ sort compare a str default against a float and raise TypeError out of the
280
+ listing — every caller, including the TUI's interval poll, treats that as an
281
+ unreadable store rather than one odd row.
282
+ """
283
+ value = row.get("created_at")
284
+ return value if isinstance(value, (int, float)) else 0.0
285
+
286
+
287
+ def _runtime_dir() -> Path:
288
+ uid = os.getuid() if hasattr(os, "getuid") else os.getpid()
289
+ xdg_runtime = os.environ.get("XDG_RUNTIME_DIR")
290
+ root = (
291
+ Path(xdg_runtime) / "syncmill"
292
+ if xdg_runtime
293
+ else Path(tempfile.gettempdir()) / f"syncmill-{uid}"
294
+ )
295
+ try:
296
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
297
+ info = root.lstat()
298
+ except OSError as exc:
299
+ raise JobError(f"cannot prepare secure runtime directory: {exc}") from exc
300
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
301
+ raise JobError(f"unsafe runtime path: {root}")
302
+ if hasattr(os, "getuid") and info.st_uid != os.getuid():
303
+ raise JobError(f"runtime directory is owned by another user: {root}")
304
+ if info.st_mode & 0o077:
305
+ os.chmod(root, 0o700)
306
+ return root
307
+
308
+
309
+ def _socket_path(job_id: str) -> Path:
310
+ """One control socket PER JOB (was per-project) — so many jobs coexist."""
311
+ return _runtime_dir() / f"tui-{job_id[:24]}.sock"
312
+
313
+
314
+ class JobStore:
315
+ def __init__(
316
+ self,
317
+ config: TuiConfig,
318
+ project_root: str | Path,
319
+ *,
320
+ board_db: str | Path | None = None,
321
+ ):
322
+ try:
323
+ resolved_root = Path(project_root).resolve()
324
+ except (OSError, RuntimeError) as exc:
325
+ raise JobError(
326
+ f"cannot resolve project root {project_root!r}: {exc}"
327
+ ) from exc
328
+ try:
329
+ # Resolve to an ABSOLUTE path: job dirs are derived from this and
330
+ # passed to a worker launched with cwd=project_root, so a relative
331
+ # state dir would be created and looked up against different cwds.
332
+ state_root = Path(config.state_dir).expanduser().resolve()
333
+ except (OSError, RuntimeError) as exc:
334
+ raise JobError(
335
+ f"cannot resolve TUI state directory {config.state_dir!r}: {exc}"
336
+ ) from exc
337
+ resolved_board: Path | None = None
338
+ if board_db is not None:
339
+ try:
340
+ resolved_board = Path(board_db).expanduser().resolve()
341
+ except (OSError, RuntimeError) as exc:
342
+ raise JobError(
343
+ f"cannot resolve board database path {board_db!r}: {exc}"
344
+ ) from exc
345
+ self.config = config
346
+ self.project_root = resolved_root
347
+ self.board_db = resolved_board
348
+ # The store's stable identity; also its state root directory name.
349
+ self.key = project_key(resolved_root)
350
+ self.root = state_root / self.key
351
+ self.jobs_dir = self.root / "jobs"
352
+ self.locks_dir = self.root / "locks"
353
+ # Schema-1 board jobs lived under a board-keyed root with a single
354
+ # worker.lock; read (never write) them via the compat overlay.
355
+ self.legacy_root = (
356
+ state_root / board_key(resolved_board) if resolved_board else None
357
+ )
358
+
359
+ # -- directory / listing ------------------------------------------------
360
+
361
+ def ensure(self) -> None:
362
+ self.jobs_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
363
+ self.locks_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
364
+ os.chmod(self.root, 0o700)
365
+ os.chmod(self.jobs_dir, 0o700)
366
+
367
+ def _read_jobs_dir(self, jobs_dir: Path, *, origin: str) -> list[dict[str, Any]]:
368
+ if not jobs_dir.is_dir():
369
+ return []
370
+ rows: list[dict[str, Any]] = []
371
+ for directory in jobs_dir.iterdir():
372
+ if not directory.is_dir():
373
+ continue
374
+ state = _read_json(directory / "state.json")
375
+ spec = _read_json(directory / "spec.json")
376
+ if state is None or spec is None:
377
+ continue
378
+ rows.append({**spec, **state, "job_dir": str(directory), "origin": origin})
379
+ return rows
380
+
381
+ def _bridge_held_by_us(self) -> bool:
382
+ """True when a live schema-2 board-run job for THIS store's board db holds
383
+ the legacy bridge lock — so the held legacy ``worker.lock`` is ours, NOT a
384
+ schema-1 worker's, and no legacy row may be read as active on its strength.
385
+
386
+ The project jobs_dir is shared across stores of the same project but
387
+ different board dbs, so match the canonical board_db: a live board-run job
388
+ for a DIFFERENT board must not mask a genuine schema-1 job for this one."""
389
+ if self.board_db is None:
390
+ return False
391
+ board_db = str(self.board_db)
392
+ for row in self._read_jobs_dir(self.jobs_dir, origin="project"):
393
+ if (
394
+ row.get("op") == "board-run"
395
+ and row.get("board_db") == board_db
396
+ and row.get("status") not in _TERMINAL
397
+ ):
398
+ held, owner = self._lock_state(row.get("lock_key"))
399
+ if held and owner == row.get("job_id"):
400
+ return True
401
+ return False
402
+
403
+ def _overlay_legacy(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
404
+ """Ephemeral READ overlay for schema-1 board jobs (never written back).
405
+
406
+ Schema-1 had a SINGLE worker.lock + active.json, so at most ONE legacy row
407
+ can be genuinely running: the active.json pointer, and only while a
408
+ schema-1 worker (not our own schema-2 bridge) holds the lock. Every other
409
+ non-terminal legacy row is stale → shown as ``lost``."""
410
+ pointer = None
411
+ ptr_doc = _read_json(self.legacy_root / "active.json") if self.legacy_root else None
412
+ if ptr_doc and ptr_doc.get("job_id"):
413
+ pointer = str(ptr_doc["job_id"])
414
+ live_legacy = self._legacy_lock_held() and not self._bridge_held_by_us()
415
+ out: list[dict[str, Any]] = []
416
+ for row in rows:
417
+ if row.get("status") not in _TERMINAL:
418
+ if not (live_legacy and row.get("job_id") == pointer):
419
+ row = {**row, "status": "lost"}
420
+ out.append(row)
421
+ return out
422
+
423
+ def list_jobs(self) -> list[dict[str, Any]]:
424
+ rows = self._read_jobs_dir(self.jobs_dir, origin="project")
425
+ if self.legacy_root is not None:
426
+ rows += self._overlay_legacy(
427
+ self._read_jobs_dir(self.legacy_root / "jobs", origin="legacy")
428
+ )
429
+ return sorted(rows, key=_created_at, reverse=True)
430
+
431
+ def active_jobs(self) -> list[dict[str, Any]]:
432
+ """Non-terminal jobs that genuinely own their target lock (held AND owner
433
+ matches). A schema-2 row whose lock is released, or whose target was
434
+ taken over by a newer job, is reconciled to ``lost`` in place."""
435
+ out: list[dict[str, Any]] = []
436
+ for row in self.list_jobs():
437
+ if row.get("origin") == "legacy":
438
+ if row.get("status") not in _TERMINAL:
439
+ out.append(row)
440
+ continue
441
+ resolved = self._reconcile(row)
442
+ if resolved is not None and resolved.get("status") not in _TERMINAL:
443
+ out.append(resolved)
444
+ return out
445
+
446
+ def active_job(self) -> dict[str, Any] | None:
447
+ """Compatibility helper for "is anything running?" callers ONLY — never
448
+ used to address a control request now that concurrency is enabled."""
449
+ jobs = self.active_jobs()
450
+ return jobs[0] if jobs else None
451
+
452
+ # -- locking ------------------------------------------------------------
453
+
454
+ def _lock_state(self, lock_key: str | None) -> tuple[bool, str | None]:
455
+ """``(held, owner_job_id)`` for a per-target lock file. The owner is read
456
+ from the lock file's contents (written under the lock), so a released-then-
457
+ reacquired target is attributable to its current holder.
458
+
459
+ The probe takes a SHARED lock, never an exclusive one: it still fails
460
+ against a real holder (every acquisition path takes ``LOCK_EX``), but a
461
+ poll tick must not transiently hold the target lock itself — an exclusive
462
+ probe makes a concurrent launch see spurious contention."""
463
+ if fcntl is None or not lock_key:
464
+ return (False, None)
465
+ path = self.locks_dir / f"{lock_key}.lock"
466
+ if not path.exists():
467
+ return (False, None)
468
+ try:
469
+ fd = os.open(path, os.O_RDWR)
470
+ except OSError:
471
+ return (False, None)
472
+ try:
473
+ try:
474
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
475
+ except BlockingIOError:
476
+ owner = os.pread(fd, 256, 0).decode("utf-8", "replace").strip()
477
+ return (True, owner or None)
478
+ fcntl.flock(fd, fcntl.LOCK_UN)
479
+ return (False, None)
480
+ finally:
481
+ os.close(fd)
482
+
483
+ def _legacy_lock_held(self) -> bool:
484
+ """Shared-lock probe, for the same reason as :meth:`_lock_state`."""
485
+ if fcntl is None or self.legacy_root is None:
486
+ return False
487
+ legacy_lock = self.legacy_root / "worker.lock"
488
+ if not legacy_lock.exists():
489
+ return False
490
+ try:
491
+ fd = os.open(legacy_lock, os.O_RDWR)
492
+ except OSError:
493
+ return False
494
+ try:
495
+ try:
496
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
497
+ except BlockingIOError:
498
+ return True
499
+ fcntl.flock(fd, fcntl.LOCK_UN)
500
+ return False
501
+ finally:
502
+ os.close(fd)
503
+
504
+ def _reconcile(self, row: dict[str, Any]) -> dict[str, Any] | None:
505
+ if row.get("status") in _TERMINAL:
506
+ return row
507
+ held, owner = self._lock_state(row.get("lock_key"))
508
+ if held and owner == row.get("job_id"):
509
+ return row
510
+ # Lock released, or a replacement job owns the target → this row is lost.
511
+ state_path = Path(row["job_dir"]) / "state.json"
512
+ state = _read_json(state_path) or {}
513
+ if state.get("status") in _TERMINAL:
514
+ return {**row, **state}
515
+ state.update(
516
+ status="lost",
517
+ finished_at=time.time(),
518
+ error="worker lock released without terminal receipt",
519
+ )
520
+ _atomic_json(state_path, state)
521
+ return {**row, **state}
522
+
523
+ def _reconcile_superseded(self, lock_key: str, new_job_id: str) -> None:
524
+ """After WE acquire ``lock_key``, any older non-terminal job for the same
525
+ target is stale (we own it now) — mark them lost."""
526
+ for row in self._read_jobs_dir(self.jobs_dir, origin="project"):
527
+ if (
528
+ row.get("lock_key") == lock_key
529
+ and row.get("job_id") != new_job_id
530
+ and row.get("status") not in _TERMINAL
531
+ ):
532
+ state_path = Path(row["job_dir"]) / "state.json"
533
+ state = _read_json(state_path) or {}
534
+ if state.get("status") in _TERMINAL:
535
+ continue
536
+ state.update(
537
+ status="lost",
538
+ finished_at=time.time(),
539
+ error="superseded by a new worker for the same target",
540
+ )
541
+ _atomic_json(state_path, state)
542
+
543
+ def _open_exclusive(self, path: Path) -> int:
544
+ """Acquire the target lock, or fail immediately if anything holds it.
545
+
546
+ Fail-fast is deliberate and must stay: contention means a launch for
547
+ this target is not ours to take, and every way of waiting it out ends up
548
+ letting a duplicate through — a holder that finishes inside the wait is
549
+ indistinguishable from a probe that finishes inside it, because flock
550
+ offers no acquire-free query and no way to make "classify" and "acquire"
551
+ one atomic step.
552
+
553
+ A residual window is therefore accepted: a poll-tick probe
554
+ (:meth:`_lock_state`) holds its SHARED lock for an instant, and a launch
555
+ landing exactly there is refused as though a worker held the target. It
556
+ is rare, self-correcting (the user relaunches), and strictly preferable
557
+ to running a duplicate job. Closing it properly needs a second
558
+ coordination lock that probes and launches share — deliberately not
559
+ built for a symptom no one has reported hitting.
560
+ """
561
+ fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
562
+ try:
563
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
564
+ except BaseException:
565
+ # Close on ANY flock failure (contention or otherwise); callers
566
+ # translate BlockingIOError into a JobError.
567
+ os.close(fd)
568
+ raise
569
+ return fd
570
+
571
+ def _target_key_for(self, spec: JobSpec) -> str:
572
+ return _target_lock_key(spec, project_key=self.key, board_db=self.board_db)
573
+
574
+ def _try_target_lock(self, lock_key: str, job_id: str) -> int:
575
+ if fcntl is None:
576
+ raise JobError("durable TUI execution requires a POSIX platform")
577
+ self.locks_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
578
+ # Lock files are PERMANENT (never unlinked): unlinking a held inode would
579
+ # let a second launcher create a new inode and win a second lock.
580
+ path = self.locks_dir / f"{lock_key}.lock"
581
+ try:
582
+ fd = self._open_exclusive(path)
583
+ except BlockingIOError:
584
+ raise JobError("a TUI-managed worker is already active for this target")
585
+ try:
586
+ # Record ownership under the lock so active_jobs can attribute the
587
+ # target. On any failure close the fd — never leak a held lock.
588
+ os.ftruncate(fd, 0)
589
+ os.pwrite(fd, job_id.encode(), 0)
590
+ os.fsync(fd)
591
+ except BaseException:
592
+ os.close(fd)
593
+ raise
594
+ return fd
595
+
596
+ def _try_legacy_lock(self) -> int | None:
597
+ """Bridge schema-1 exclusion: a schema-2 board launch also holds the legacy
598
+ board ``worker.lock`` so a concurrent OLD-binary board run is excluded."""
599
+ if self.legacy_root is None:
600
+ return None
601
+ self.legacy_root.mkdir(parents=True, exist_ok=True, mode=0o700)
602
+ legacy_lock = self.legacy_root / "worker.lock"
603
+ try:
604
+ return self._open_exclusive(legacy_lock)
605
+ except BlockingIOError:
606
+ raise JobError("a TUI-managed worker is already active for this board")
607
+
608
+ # -- launch / control ---------------------------------------------------
609
+
610
+ def _frozen_config_payload(self, resolved_config: SyncmillConfig) -> dict[str, Any]:
611
+ """A model_dump of the resolved config with the correctness-state paths
612
+ absolutized against the store project root (mirrors launch_room_worker),
613
+ so the detached apply worker consumes a self-contained, path-pinned config
614
+ and never re-reads mutable ambient TOML/env at spawn time."""
615
+ payload = resolved_config.model_dump(mode="json")
616
+ base = self.project_root
617
+
618
+ def absolute(value: str) -> str:
619
+ path = Path(value).expanduser()
620
+ return str(path.resolve() if path.is_absolute() else (base / path).resolve())
621
+
622
+ payload["repo_root"] = str(base)
623
+ payload["runs"]["root"] = absolute(payload["runs"]["root"])
624
+ payload["workspace"]["root"] = absolute(payload["workspace"]["root"])
625
+ return payload
626
+
627
+ def launch(
628
+ self,
629
+ spec: JobSpec,
630
+ *,
631
+ cwd: str | Path | None = None,
632
+ resolved_config: SyncmillConfig | None = None,
633
+ ) -> dict[str, Any]:
634
+ # The store OWNS the repository boundary: the worker always runs in the
635
+ # store's project root, never an arbitrary caller cwd.
636
+ cwd_path = self.project_root
637
+ if cwd is not None:
638
+ try:
639
+ given = Path(cwd).resolve()
640
+ except (OSError, RuntimeError) as exc:
641
+ raise JobError(f"cannot resolve cwd {cwd!r}: {exc}") from exc
642
+ if given != cwd_path:
643
+ raise JobError(
644
+ f"cwd {given} does not match the store project root {cwd_path}"
645
+ )
646
+ # ensure() owns the 0700 hardening on the store root and jobs/ — the
647
+ # later job_dir.mkdir(parents=True) applies its mode only to the leaf,
648
+ # so without this the parents would silently keep umask perms. Its
649
+ # filesystem failures translate to JobError like every other launch
650
+ # failure (callers handle only JobError).
651
+ try:
652
+ self.ensure()
653
+ except OSError as exc:
654
+ raise JobError(f"cannot prepare the job store: {exc}") from exc
655
+ job_id = str(uuid4())
656
+ lock_key = self._target_key_for(spec)
657
+ job_dir = self.jobs_dir / job_id
658
+ created = time.time()
659
+ # A pinned resolved-config snapshot (apply ops) — the worker consumes and
660
+ # unlinks it, so a mutated ambient config can never change what was
661
+ # confirmed. Written into the job dir at launch, referenced via argv.
662
+ # It rides `extra_options` so build_job_argv can place it BEFORE the `--`
663
+ # separator; appending it here would make click read it as a positional.
664
+ snapshot_path = job_dir / "config-snapshot.json" if resolved_config else None
665
+ argv = build_job_argv(
666
+ spec,
667
+ project_root=self.project_root,
668
+ extra_options=(
669
+ ["--resolved-config", str(snapshot_path)] if snapshot_path else ()
670
+ ),
671
+ )
672
+ spec_doc = {
673
+ "schema_version": _SCHEMA_VERSION,
674
+ "job_id": job_id,
675
+ "op": spec.op,
676
+ "project_key": self.key,
677
+ "project_root": str(self.project_root),
678
+ "board_db": str(self.board_db) if self.board_db else None,
679
+ "lock_key": lock_key,
680
+ "cwd": str(cwd_path),
681
+ "created_at": created,
682
+ "request": asdict(spec),
683
+ "argv": argv,
684
+ "log_max_bytes": self.config.log_max_bytes,
685
+ }
686
+ state = {
687
+ "schema_version": _SCHEMA_VERSION,
688
+ "job_id": job_id,
689
+ "lock_key": lock_key,
690
+ "status": "starting",
691
+ "started_at": None,
692
+ "finished_at": None,
693
+ "exit_code": None,
694
+ "error": None,
695
+ "pid": None,
696
+ "pgid": None,
697
+ "cancel_requested_at": None,
698
+ }
699
+ # Acquire both locks, then do EVERYTHING under one try/finally so no path
700
+ # (incl. _reconcile_superseded's I/O) can leak a held lock fd. Lock
701
+ # I/O failures (mkdir/open/pwrite — not the JobError contention case)
702
+ # translate to JobError like the rest of launch: callers handle only
703
+ # JobError.
704
+ try:
705
+ target_fd = self._try_target_lock(lock_key, job_id)
706
+ except OSError as exc:
707
+ raise JobError(f"cannot acquire the target lock: {exc}") from exc
708
+ legacy_fd: int | None = None
709
+ launched = False
710
+ try:
711
+ if spec.op == "board-run":
712
+ legacy_fd = self._try_legacy_lock()
713
+ # We now own the target — retire any older job still claiming it.
714
+ self._reconcile_superseded(lock_key, job_id)
715
+ pass_fds = [target_fd] if legacy_fd is None else [target_fd, legacy_fd]
716
+ job_dir.mkdir(mode=0o700, parents=True)
717
+ if snapshot_path is not None:
718
+ # 0600 pinned snapshot (via _atomic_json); the worker consumes+unlinks.
719
+ _atomic_json(snapshot_path, self._frozen_config_payload(resolved_config))
720
+ _atomic_json(job_dir / "spec.json", spec_doc)
721
+ _atomic_json(job_dir / "state.json", state)
722
+ token = secrets.token_hex(32)
723
+ token_path = job_dir / "control.token"
724
+ token_path.write_text(token, encoding="ascii")
725
+ os.chmod(token_path, 0o600)
726
+ worker_argv = [
727
+ sys.executable, "-m", "syncmill.tui_jobs", "worker",
728
+ "--job-dir", str(job_dir), "--lock-fd", str(target_fd),
729
+ ]
730
+ if legacy_fd is not None:
731
+ worker_argv += ["--legacy-lock-fd", str(legacy_fd)]
732
+ bootstrap_path = job_dir / "manager.log"
733
+ with bootstrap_path.open("ab", buffering=0) as bootstrap:
734
+ os.chmod(bootstrap_path, 0o600)
735
+ subprocess.Popen(
736
+ worker_argv,
737
+ cwd=cwd_path,
738
+ stdin=subprocess.DEVNULL,
739
+ stdout=bootstrap,
740
+ stderr=subprocess.STDOUT,
741
+ start_new_session=True,
742
+ close_fds=True,
743
+ pass_fds=tuple(pass_fds),
744
+ )
745
+ # The spawn succeeded the instant Popen returns; the child holds
746
+ # its own dup of the bootstrap fd, so a later bootstrap-close
747
+ # error must NOT be misread as a failed launch.
748
+ launched = True
749
+ except Exception as exc:
750
+ # ONLY a genuine spawn failure cleans up. A post-Popen error (e.g.
751
+ # the bootstrap-log close raising) leaves a running worker — deleting
752
+ # its config snapshot (which it consumes+unlinks) or recording
753
+ # launch_failed over a live worker would corrupt it.
754
+ if not launched:
755
+ # Cleanup is best-effort: a nested filesystem failure (disk
756
+ # full, EACCES) must not mask the JobError below with a raw
757
+ # OSError escaping the documented boundary.
758
+ try:
759
+ if snapshot_path is not None:
760
+ snapshot_path.unlink(missing_ok=True)
761
+ if job_dir.is_dir():
762
+ state.update(
763
+ status="launch_failed",
764
+ finished_at=time.time(),
765
+ error=str(exc),
766
+ )
767
+ _atomic_json(job_dir / "state.json", state)
768
+ except OSError:
769
+ pass
770
+ raise JobError(f"failed to launch TUI worker: {exc}") from exc
771
+ finally:
772
+ os.close(target_fd)
773
+ if legacy_fd is not None:
774
+ os.close(legacy_fd)
775
+ if launched:
776
+ self.prune()
777
+ return {**spec_doc, **state, "job_dir": str(job_dir)}
778
+
779
+ def prune(self) -> None:
780
+ # Prune ONLY schema-2 project jobs; legacy dirs are read-only overlays,
781
+ # and an unknown/future-version row under the project store is left alone.
782
+ terminal = [
783
+ row
784
+ for row in self._read_jobs_dir(self.jobs_dir, origin="project")
785
+ if row.get("status") in _TERMINAL and row.get("schema_version") == 2
786
+ ]
787
+ terminal.sort(key=_created_at, reverse=True)
788
+ for row in terminal[self.config.keep_jobs :]:
789
+ directory = Path(row["job_dir"])
790
+ for path in sorted(directory.glob("*"), reverse=True):
791
+ try:
792
+ path.unlink()
793
+ except OSError:
794
+ pass
795
+ try:
796
+ directory.rmdir()
797
+ except OSError:
798
+ pass
799
+
800
+ def control(self, job_id: str, action: str) -> dict[str, Any]:
801
+ job_dir = self.jobs_dir / str(job_id)
802
+ state = _read_json(job_dir / "state.json")
803
+ spec = _read_json(job_dir / "spec.json")
804
+ if state is None or spec is None:
805
+ raise JobError(f"no such TUI job: {job_id}")
806
+ if state.get("status") in _TERMINAL:
807
+ raise JobError("job is no longer running")
808
+ if action == "force":
809
+ requested = state.get("cancel_requested_at")
810
+ if not requested or time.time() - float(requested) < _FORCE_GRACE_SECONDS:
811
+ raise JobError("force stop is available 10 seconds after safe stop")
812
+ # The manager can finish or die between the poll that showed this job and
813
+ # the keypress that controls it, taking the token file and the socket with
814
+ # it. Every step from here is inside the JobError boundary the callers
815
+ # (and the TUI's stop handler) expect — a raw FileNotFoundError or
816
+ # JSONDecodeError escaping would crash the UI instead of reporting that
817
+ # the worker is gone.
818
+ try:
819
+ token = (job_dir / "control.token").read_text(encoding="ascii")
820
+ payload = json.dumps(
821
+ {"job_id": str(job_id), "token": token, "action": action}
822
+ ).encode() + b"\n"
823
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
824
+ client.settimeout(2.0)
825
+ client.connect(str(_socket_path(str(job_id))))
826
+ client.sendall(payload)
827
+ raw = client.recv(65536)
828
+ reply = json.loads(raw.decode("utf-8", errors="replace"))
829
+ except (OSError, ValueError) as exc:
830
+ # ValueError covers json.JSONDecodeError: a manager that died
831
+ # mid-reply leaves an empty or truncated response.
832
+ raise JobError(f"worker control unavailable: {exc}") from exc
833
+ if not isinstance(reply, dict):
834
+ raise JobError("worker control returned an unexpected reply")
835
+ if not reply.get("ok"):
836
+ raise JobError(str(reply.get("error") or "worker rejected control request"))
837
+ return reply
838
+
839
+
840
+ class _RollingLog:
841
+ def __init__(self, job_dir: Path, max_bytes: int):
842
+ self.current = job_dir / "console.log"
843
+ self.previous = job_dir / "console.log.1"
844
+ self.segment = max(512, max_bytes // 2)
845
+ self._fh = self.current.open("ab", buffering=0)
846
+ os.chmod(self.current, 0o600)
847
+
848
+ def write(self, chunk: bytes) -> None:
849
+ view = memoryview(chunk)
850
+ while view:
851
+ room = self.segment - self._fh.tell()
852
+ if room <= 0:
853
+ self._rotate()
854
+ room = self.segment
855
+ piece = view[:room]
856
+ self._fh.write(piece)
857
+ view = view[len(piece) :]
858
+
859
+ def _rotate(self) -> None:
860
+ self._fh.close()
861
+ try:
862
+ os.replace(self.current, self.previous)
863
+ except FileNotFoundError:
864
+ pass
865
+ self._fh = self.current.open("ab", buffering=0)
866
+ os.chmod(self.current, 0o600)
867
+
868
+ def close(self) -> None:
869
+ self._fh.close()
870
+
871
+
872
+ class _Manager:
873
+ def __init__(self, job_dir: Path, lock_fd: int, legacy_lock_fd: int = -1):
874
+ self.job_dir = job_dir
875
+ self.lock_fd = lock_fd
876
+ # Threaded through child -> guardian (like lock_fd) so the schema-1
877
+ # exclusion bridge survives manager death: if the manager is killed, the
878
+ # guardian still holds it while it tears the child's group down, so an old
879
+ # schema-1 launcher cannot grab the legacy lock mid-cleanup.
880
+ self.legacy_lock_fd = legacy_lock_fd
881
+ self.spec = _read_json(job_dir / "spec.json") or {}
882
+ self.state_path = job_dir / "state.json"
883
+ self.state = _read_json(self.state_path) or {}
884
+ self.token = (job_dir / "control.token").read_text(encoding="ascii")
885
+ self.job_id = str(self.spec.get("job_id", ""))
886
+ self.child: subprocess.Popen[bytes] | None = None
887
+ self._state_lock = threading.Lock()
888
+ self._stop_server = threading.Event()
889
+ self._cancel_requested = False
890
+ self._force_requested = False
891
+ # A ONE-WAY latch under _state_lock: once the run records a terminal
892
+ # state, no control request may overwrite it, and only the success path
893
+ # (which sets this) may ack the guardian.
894
+ self._finalized = False
895
+
896
+ def _update(self, **values: Any) -> None:
897
+ with self._state_lock:
898
+ self.state.update(values)
899
+ _atomic_json(self.state_path, self.state)
900
+
901
+ def _mark_running(self, pid: int) -> None:
902
+ """Publish the running transition WITHOUT clobbering a control state that
903
+ already arrived (a stop accepted between Popen and this write): keep the
904
+ control status if it is no longer 'starting'."""
905
+ with self._state_lock:
906
+ self.state.update(started_at=time.time(), pid=pid, pgid=pid)
907
+ if self.state.get("status") == "starting":
908
+ self.state["status"] = "running"
909
+ _atomic_json(self.state_path, self.state)
910
+
911
+ def _apply_control(self, action: str) -> str:
912
+ """Deliver a control signal and publish its intent ATOMICALLY under the
913
+ state lock: refuse once finalized, deliver the signal, and commit the flag
914
+ + state ONLY after the kernel accepts it — so a failed signal never leaves
915
+ a misleading cancelled/force_stopped receipt, and a concurrent finalize
916
+ either wins (this refuses) or loses (it sees the flag). The force-grace
917
+ read is part of the same transaction."""
918
+ with self._state_lock:
919
+ if self._finalized:
920
+ raise JobError("job is finalizing")
921
+ if self.child is None or self.child.poll() is not None:
922
+ raise JobError("worker child is not running")
923
+ if action == "stop":
924
+ sig, status = signal.SIGINT, "cancel_requested"
925
+ elif action == "force":
926
+ requested = self.state.get("cancel_requested_at")
927
+ if not requested or time.time() - float(requested) < _FORCE_GRACE_SECONDS:
928
+ raise JobError("force stop is available 10 seconds after safe stop")
929
+ sig, status = signal.SIGKILL, "force_stop_requested"
930
+ else:
931
+ raise JobError("unknown control action")
932
+ try:
933
+ os.killpg(self.child.pid, sig)
934
+ except OSError as exc:
935
+ raise JobError(f"could not signal worker: {exc}") from exc
936
+ # Kernel accepted the signal — NOW commit the intent + durable state.
937
+ if action == "stop":
938
+ self._cancel_requested = True
939
+ self.state.update(status=status, cancel_requested_at=time.time())
940
+ else:
941
+ self._force_requested = True
942
+ self.state.update(status=status)
943
+ _atomic_json(self.state_path, self.state)
944
+ return status
945
+
946
+ def _finalize_completion(self, code: int) -> int:
947
+ """One-way terminal latch for a NORMAL child exit. Reads the cancel/force
948
+ flags and writes the terminal state atomically under the lock, so a
949
+ concurrent control request cannot interleave and overwrite it. Returns the
950
+ normalized exit code."""
951
+ with self._state_lock:
952
+ if self._force_requested:
953
+ status, normalized = "force_stopped", 137
954
+ elif self._cancel_requested:
955
+ status, normalized = "cancelled", 130
956
+ else:
957
+ status, normalized = "finished", code
958
+ self._finalized = True
959
+ self.state.update(status=status, exit_code=normalized, finished_at=time.time())
960
+ _atomic_json(self.state_path, self.state)
961
+ return normalized
962
+
963
+ def _finalize_crash(self, **values: Any) -> None:
964
+ """One-way terminal latch for the crash path — a NO-OP if the run already
965
+ finalized cleanly, so an async exception after a clean finalize cannot
966
+ overwrite the terminal state with ``lost``."""
967
+ with self._state_lock:
968
+ if self._finalized:
969
+ return
970
+ self._finalized = True
971
+ self.state.update(values)
972
+ _atomic_json(self.state_path, self.state)
973
+
974
+ def run(self) -> int:
975
+ sock_path = _socket_path(self.job_id)
976
+ try:
977
+ sock_path.unlink()
978
+ except FileNotFoundError:
979
+ pass
980
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
981
+ # umask over the bind so the socket is never connectable at a mode
982
+ # wider than 0600, even inside the bind→chmod window (the 0700 runtime
983
+ # dir already excludes other users; this is defence in depth).
984
+ old_umask = os.umask(0o077)
985
+ try:
986
+ server.bind(str(sock_path))
987
+ finally:
988
+ os.umask(old_umask)
989
+ os.chmod(sock_path, 0o600)
990
+ server.listen(4)
991
+ server.settimeout(0.25)
992
+ thread = threading.Thread(target=self._serve, args=(server,), daemon=True)
993
+ thread.start()
994
+ log = _RollingLog(self.job_dir, int(self.spec.get("log_max_bytes", 10 << 20)))
995
+ manager_read, manager_write = os.pipe()
996
+ try:
997
+ child_env = os.environ.copy()
998
+ child_env["SYNCMILL_TUI_JOB_ID"] = self.job_id
999
+ if self.spec.get("op") == "board-run" and self.spec.get("board_db"):
1000
+ child_env["SYNCMILL_BOARD__ENABLED"] = "true"
1001
+ child_env["SYNCMILL_BOARD__DB_PATH"] = str(self.spec["board_db"])
1002
+ child_argv = [
1003
+ sys.executable, "-m", "syncmill.tui_jobs", "child",
1004
+ "--job-dir", str(self.job_dir),
1005
+ "--lock-fd", str(self.lock_fd),
1006
+ "--manager-fd", str(manager_read),
1007
+ ]
1008
+ pass_fds = [self.lock_fd, manager_read]
1009
+ if self.legacy_lock_fd >= 0:
1010
+ child_argv += ["--legacy-lock-fd", str(self.legacy_lock_fd)]
1011
+ pass_fds.append(self.legacy_lock_fd)
1012
+ child_argv += ["--", *self.spec["argv"]]
1013
+ self.child = subprocess.Popen(
1014
+ child_argv,
1015
+ cwd=self.spec["cwd"],
1016
+ stdin=subprocess.DEVNULL,
1017
+ stdout=subprocess.PIPE,
1018
+ stderr=subprocess.STDOUT,
1019
+ start_new_session=True,
1020
+ close_fds=True,
1021
+ pass_fds=tuple(pass_fds),
1022
+ env=child_env,
1023
+ )
1024
+ os.close(manager_read)
1025
+ manager_read = -1
1026
+ self._mark_running(self.child.pid)
1027
+ assert self.child.stdout is not None
1028
+ # read1, not read: BufferedReader.read(n) blocks until n bytes or
1029
+ # EOF, so a low-volume run wrote NOTHING to the log until 64KiB
1030
+ # accumulated or the job exited — the TUI tail looked frozen for the
1031
+ # whole run. read1 returns whatever the pipe currently holds.
1032
+ while chunk := self.child.stdout.read1(64 * 1024):
1033
+ log.write(chunk)
1034
+ code = self.child.wait()
1035
+ normalized = self._finalize_completion(code)
1036
+ # Terminal state is durably recorded and control can no longer
1037
+ # overwrite it → ack the guardian so it stands down WITHOUT tearing the
1038
+ # group down. Emitted ONLY here (the success path); the except/finally
1039
+ # never ack, so a crash always leaves the guardian to clean up. An
1040
+ # async exception after this point re-enters the handler but the
1041
+ # one-way latch drops its write, keeping ack ⟹ durable terminal.
1042
+ try:
1043
+ os.write(manager_write, _MANAGER_DONE)
1044
+ except OSError:
1045
+ pass
1046
+ return normalized
1047
+ except BaseException as exc:
1048
+ self._finalize_crash(
1049
+ status="launch_failed" if self.child is None else "lost",
1050
+ finished_at=time.time(),
1051
+ error=f"{type(exc).__name__}: {exc}",
1052
+ )
1053
+ return 1
1054
+ finally:
1055
+ if manager_read >= 0:
1056
+ os.close(manager_read)
1057
+ os.close(manager_write)
1058
+ log.close()
1059
+ self._stop_server.set()
1060
+ server.close()
1061
+ thread.join(timeout=1)
1062
+ try:
1063
+ sock_path.unlink()
1064
+ except FileNotFoundError:
1065
+ pass
1066
+
1067
+ def _serve(self, server: socket.socket) -> None:
1068
+ while not self._stop_server.is_set():
1069
+ try:
1070
+ conn, _ = server.accept()
1071
+ except (TimeoutError, OSError):
1072
+ continue
1073
+ with conn:
1074
+ try:
1075
+ request = json.loads(conn.recv(65536).decode("utf-8"))
1076
+ reply = self._handle_control(request)
1077
+ except Exception as exc:
1078
+ reply = {"ok": False, "error": str(exc)}
1079
+ conn.sendall(json.dumps(reply).encode())
1080
+
1081
+ def _handle_control(self, request: dict[str, Any]) -> dict[str, Any]:
1082
+ # Token first (constant-time), job id second, ONE generic error for
1083
+ # either: checking the id before auth let an unauthenticated caller
1084
+ # distinguish "job id mismatch" from "invalid token" — a pre-auth
1085
+ # existence oracle (the error string is echoed back verbatim).
1086
+ authorized = secrets.compare_digest(str(request.get("token", "")), self.token)
1087
+ if not authorized or request.get("job_id") != self.spec.get("job_id"):
1088
+ raise JobError("unauthorized")
1089
+ # Signal delivery + intent publication + the finalize/grace gates are all
1090
+ # serialized under the state lock in _apply_control.
1091
+ status = self._apply_control(str(request.get("action")))
1092
+ return {"ok": True, "status": status}
1093
+
1094
+
1095
+ def _process_group_exists(pgid: int, *, allow_zombie_only: bool = False) -> bool:
1096
+ """Whether the group still needs process control.
1097
+
1098
+ ``allow_zombie_only`` opts into the Linux procfs refinement that treats a
1099
+ zombie-only group as gone. Callers may pass it ONLY on checks that run
1100
+ AFTER a SIGKILL has been sent to the group: before that, a SIGINT-ignoring
1101
+ member can fork a replacement in a scan's blind window, and trusting the
1102
+ zombie-only verdict would let the guardian skip the kill loop and release
1103
+ its locks while that replacement still runs (fail-closed violation).
1104
+ Post-SIGKILL the fork window is closed for every signaled member, and the
1105
+ refinement only breaks the genuine unreapable-zombie deadlock.
1106
+ """
1107
+ try:
1108
+ os.killpg(pgid, 0)
1109
+ except ProcessLookupError:
1110
+ return False
1111
+ except PermissionError:
1112
+ return True
1113
+ if allow_zombie_only:
1114
+ live_members = _linux_process_group_has_live_members(pgid)
1115
+ if live_members is not None:
1116
+ return live_members
1117
+ return True
1118
+
1119
+
1120
+ _PROC_RESCAN_DELAY_SECONDS = 0.01
1121
+
1122
+
1123
+ def _linux_process_group_has_live_members(
1124
+ pgid: int, proc_root: Path = Path("/proc")
1125
+ ) -> bool | None:
1126
+ """Return whether a Linux process group has a runnable member.
1127
+
1128
+ ``killpg(pgid, 0)`` also succeeds for a group containing only zombies. A
1129
+ guardian cannot reap a target owned by its test/launcher parent, and looping
1130
+ until that parent calls ``wait()`` deadlocks both sides. Zombies have
1131
+ already closed every inherited lock fd and cannot execute, so a zombie-only
1132
+ group is complete for guardian cleanup purposes.
1133
+
1134
+ A single snapshot is not trusted: a member can fork a same-group child
1135
+ after the directory listing and exit before its own stat is read, hiding
1136
+ both from one scan. A zombie-only verdict therefore requires the group's
1137
+ zombies to be actually observed AND an immediate rescan (which lists any
1138
+ child forked before the first scan concluded) to agree; a scan that saw no
1139
+ member at all despite ``killpg`` succeeding is uncertain.
1140
+
1141
+ ``None`` means procfs is unavailable or could not be inspected completely;
1142
+ callers then retain the conservative ``killpg`` result.
1143
+ """
1144
+ if not sys.platform.startswith("linux") or not proc_root.is_dir():
1145
+ return None
1146
+ for attempt in range(2):
1147
+ verdict = _scan_linux_process_group(pgid, proc_root)
1148
+ if verdict is not False:
1149
+ return verdict
1150
+ if attempt == 0:
1151
+ time.sleep(_PROC_RESCAN_DELAY_SECONDS)
1152
+ return False
1153
+
1154
+
1155
+ def _scan_linux_process_group(pgid: int, proc_root: Path) -> bool | None:
1156
+ """One procfs pass: True = live member, False = observed members are all
1157
+ zombies, None = uncertain (unreadable entries, or no member surfaced)."""
1158
+ members_seen = 0
1159
+ uncertain = False
1160
+ try:
1161
+ entries = list(proc_root.iterdir())
1162
+ except OSError:
1163
+ return None
1164
+ for entry in entries:
1165
+ if not entry.name.isdigit():
1166
+ continue
1167
+ try:
1168
+ fields = (
1169
+ (entry / "stat")
1170
+ .read_text(encoding="utf-8")
1171
+ .rsplit(") ", 1)[1]
1172
+ .split()
1173
+ )
1174
+ state = fields[0]
1175
+ process_group = int(fields[2])
1176
+ except FileNotFoundError:
1177
+ continue # process exited between enumeration and read
1178
+ except (OSError, IndexError, ValueError):
1179
+ uncertain = True
1180
+ continue
1181
+ if process_group != pgid:
1182
+ continue
1183
+ if state not in {"Z", "X"}:
1184
+ return True
1185
+ members_seen += 1
1186
+ if uncertain:
1187
+ return None
1188
+ # killpg succeeded but no member surfaced (e.g. a parent that forked after
1189
+ # the listing and exited before its stat read): defer to the conservative
1190
+ # killpg result rather than declaring the group complete.
1191
+ return False if members_seen else None
1192
+
1193
+
1194
+ def _wait_for_group_exit(
1195
+ pgid: int, timeout: float, *, allow_zombie_only: bool = False
1196
+ ) -> bool:
1197
+ """Poll until the group has no members left, bounded by ``timeout``.
1198
+
1199
+ Returns True if the group is gone (with ``allow_zombie_only``: or contains
1200
+ only zombies — valid only after a SIGKILL, see ``_process_group_exists``).
1201
+ The process-group survival check is authoritative — a completion-pipe EOF
1202
+ proves only the CHILD exited, not a SIGINT-ignoring descendant sharing the
1203
+ group.
1204
+ """
1205
+ deadline = time.monotonic() + timeout
1206
+ while _process_group_exists(pgid, allow_zombie_only=allow_zombie_only):
1207
+ if time.monotonic() >= deadline:
1208
+ return False
1209
+ time.sleep(0.05)
1210
+ return True
1211
+
1212
+
1213
+ def _guardian_main(args: argparse.Namespace) -> int:
1214
+ manager_fd = int(args.manager_fd)
1215
+ child_fd = int(args.child_fd)
1216
+ pgid = int(args.pgid)
1217
+ selector = selectors.DefaultSelector()
1218
+ # The ONLY clean-exit signal is the manager's explicit done-ack (sent after it
1219
+ # durably records the terminal state). Child EOF is informational: the child's
1220
+ # CLI finishing does NOT prove the manager recorded the outcome, nor that the
1221
+ # group has no surviving descendants, so it never triggers exit on its own.
1222
+ acked = False
1223
+ manager_open = True
1224
+ try:
1225
+ selector.register(manager_fd, selectors.EVENT_READ, "manager")
1226
+ selector.register(child_fd, selectors.EVENT_READ, "child")
1227
+ while manager_open:
1228
+ for key, _ in selector.select():
1229
+ data = os.read(key.fd, 1)
1230
+ if key.data == "manager":
1231
+ if data == b"":
1232
+ manager_open = False
1233
+ elif data == _MANAGER_DONE:
1234
+ acked = True
1235
+ elif data == b"":
1236
+ # Child pipe EOF is informational only — drain and stop
1237
+ # watching it; the manager's ack/EOF drives the decision.
1238
+ selector.unregister(child_fd)
1239
+ finally:
1240
+ selector.close()
1241
+
1242
+ if acked:
1243
+ return 0
1244
+
1245
+ # Manager died WITHOUT a clean-completion ack — stop the child's whole process
1246
+ # group. The target and legacy-bridge lock fds are inherited (never closed
1247
+ # here) and stay held until this function returns; we FAIL CLOSED, never
1248
+ # returning while any group member survives.
1249
+ forced = False
1250
+ try:
1251
+ os.killpg(pgid, signal.SIGINT)
1252
+ except (ProcessLookupError, PermissionError):
1253
+ pass
1254
+ # Grace for the group to exit on its own after SIGINT. Deliberately
1255
+ # CONSERVATIVE (no zombie-only shortcut): a fork race during the grace
1256
+ # must escalate to SIGKILL, never end process control early; SIGKILLing a
1257
+ # group that turns out to be zombie-only is a harmless no-op.
1258
+ _wait_for_group_exit(pgid, float(args.grace))
1259
+ # Then SIGKILL any survivor (a descendant that ignored SIGINT) and keep
1260
+ # polling+re-killing until the group is truly gone — the process-group
1261
+ # survival check, never a pipe EOF, is authoritative. Only AFTER a SIGKILL
1262
+ # actually landed may the wait treat a zombie-only group as complete (the
1263
+ # unreapable-zombie deadlock fix); the loop's own gate stays conservative
1264
+ # so every remaining member is re-signaled first.
1265
+ while _process_group_exists(pgid):
1266
+ try:
1267
+ os.killpg(pgid, signal.SIGKILL)
1268
+ except ProcessLookupError:
1269
+ break # gone between the check and the signal
1270
+ except PermissionError:
1271
+ # Can't signal: a foreign group (never our own session in practice)
1272
+ # or, on some platforms, an already-dead zombie awaiting reap. If we
1273
+ # already landed a SIGKILL the group WAS force-stopped; a lingering
1274
+ # zombie is reaped once its parent is gone. Either way, stop looping.
1275
+ break
1276
+ forced = True # a SIGKILL actually landed
1277
+ if _wait_for_group_exit(pgid, float(args.grace), allow_zombie_only=True):
1278
+ break # gone, or post-SIGKILL only unreapable zombies remain
1279
+ if forced:
1280
+ message = "manager exited unexpectedly; child process group was force-stopped"
1281
+ elif not _process_group_exists(pgid):
1282
+ message = "manager exited unexpectedly; child process group was stopped"
1283
+ else:
1284
+ # Never landed a kill and the group persists — we could not signal it.
1285
+ message = (
1286
+ "manager exited unexpectedly; the child process group could not be "
1287
+ "signaled and may still be running"
1288
+ )
1289
+ state_path = Path(args.job_dir) / "state.json"
1290
+ state = _read_json(state_path) or {}
1291
+ # The latch guarantees ack ⟹ durable-terminal, not the converse: a manager
1292
+ # SIGKILLed between _finalize_completion's atomic write and the ack leaves
1293
+ # a genuine terminal receipt with no ack. Preserve it (the _worker_main
1294
+ # rule) — only a non-terminal state is honestly "lost".
1295
+ if state.get("status") not in _TERMINAL:
1296
+ state.update(status="lost", finished_at=time.time(), error=message)
1297
+ _atomic_json(state_path, state)
1298
+ return 0
1299
+
1300
+
1301
+ def _child_main(args: argparse.Namespace) -> NoReturn:
1302
+ completion_read, completion_write = os.pipe()
1303
+ legacy_fd = int(getattr(args, "legacy_lock_fd", -1))
1304
+ guardian_argv = [
1305
+ sys.executable, "-m", "syncmill.tui_jobs", "guardian",
1306
+ "--job-dir", str(args.job_dir),
1307
+ "--lock-fd", str(args.lock_fd),
1308
+ "--manager-fd", str(args.manager_fd),
1309
+ "--child-fd", str(completion_read),
1310
+ "--pgid", str(os.getpgrp()),
1311
+ "--grace", str(args.grace),
1312
+ ]
1313
+ guardian_pass_fds = [int(args.lock_fd), int(args.manager_fd), completion_read]
1314
+ if legacy_fd >= 0:
1315
+ guardian_argv += ["--legacy-lock-fd", str(legacy_fd)]
1316
+ guardian_pass_fds.append(legacy_fd)
1317
+ guardian = subprocess.Popen(
1318
+ guardian_argv,
1319
+ stdin=subprocess.DEVNULL,
1320
+ stdout=subprocess.DEVNULL,
1321
+ stderr=subprocess.DEVNULL,
1322
+ start_new_session=True,
1323
+ close_fds=True,
1324
+ pass_fds=tuple(guardian_pass_fds),
1325
+ )
1326
+ os.close(completion_read)
1327
+ os.close(int(args.manager_fd))
1328
+ os.close(int(args.lock_fd))
1329
+ if legacy_fd >= 0:
1330
+ # Release the child's copy; the guardian now holds the bridge.
1331
+ os.close(legacy_fd)
1332
+ argv = list(args.argv)
1333
+ if argv[:1] == ["--"]:
1334
+ argv = argv[1:]
1335
+ try:
1336
+ from syncmill.cli import cli
1337
+
1338
+ cli(args=argv, prog_name="syncmill")
1339
+ finally:
1340
+ os.close(completion_write)
1341
+ try:
1342
+ guardian.wait(timeout=2)
1343
+ except subprocess.TimeoutExpired:
1344
+ pass
1345
+
1346
+
1347
+ def _worker_main(args: argparse.Namespace) -> int:
1348
+ job_dir = Path(args.job_dir)
1349
+ state_path = job_dir / "state.json"
1350
+ try:
1351
+ return _Manager(
1352
+ job_dir, int(args.lock_fd), int(args.legacy_lock_fd)
1353
+ ).run()
1354
+ except BaseException as exc:
1355
+ # PRESERVE a terminal receipt already recorded by the manager (the
1356
+ # guardian may have accepted the manager's ack and stood down). A crash in
1357
+ # run()'s finally AFTER a clean finalize/ack reaches here — it must NOT
1358
+ # overwrite finished/cancelled/force_stopped/lost. Only a run that never
1359
+ # reached a terminal state becomes launch_failed.
1360
+ state = _read_json(state_path) or {}
1361
+ if state.get("status") not in _TERMINAL:
1362
+ state.update(
1363
+ status="launch_failed",
1364
+ finished_at=time.time(),
1365
+ error=f"{type(exc).__name__}: {exc}",
1366
+ )
1367
+ _atomic_json(state_path, state)
1368
+ raise
1369
+
1370
+
1371
+ def main() -> int:
1372
+ # Imported here: the worker entry stays light; only its own process
1373
+ # needs the guards (logging, uncaught exceptions, argparse output).
1374
+ from syncmill.terminal import EscapingArgumentParser, install_console_guards
1375
+
1376
+ install_console_guards()
1377
+ parser = EscapingArgumentParser()
1378
+ sub = parser.add_subparsers(dest="command", required=True)
1379
+ worker = sub.add_parser("worker")
1380
+ worker.add_argument("--job-dir", required=True)
1381
+ worker.add_argument("--lock-fd", required=True, type=int)
1382
+ worker.add_argument("--legacy-lock-fd", type=int, default=-1)
1383
+ child = sub.add_parser("child")
1384
+ child.add_argument("--job-dir", required=True)
1385
+ child.add_argument("--lock-fd", required=True, type=int)
1386
+ child.add_argument("--legacy-lock-fd", type=int, default=-1)
1387
+ child.add_argument("--manager-fd", required=True, type=int)
1388
+ child.add_argument("--grace", type=float, default=_STOP_GRACE_SECONDS)
1389
+ child.add_argument("argv", nargs=argparse.REMAINDER)
1390
+ guardian = sub.add_parser("guardian")
1391
+ guardian.add_argument("--job-dir", required=True)
1392
+ guardian.add_argument("--lock-fd", required=True, type=int)
1393
+ guardian.add_argument("--legacy-lock-fd", type=int, default=-1)
1394
+ guardian.add_argument("--manager-fd", required=True, type=int)
1395
+ guardian.add_argument("--child-fd", required=True, type=int)
1396
+ guardian.add_argument("--pgid", required=True, type=int)
1397
+ guardian.add_argument("--grace", type=float, default=_STOP_GRACE_SECONDS)
1398
+ args = parser.parse_args()
1399
+ if args.command == "worker":
1400
+ return _worker_main(args)
1401
+ if args.command == "child":
1402
+ return _child_main(args)
1403
+ return _guardian_main(args)
1404
+
1405
+
1406
+ if __name__ == "__main__":
1407
+ raise SystemExit(main())