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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- 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())
|