steppy-dev 0.3.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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Guaranteed-teardown context for the coordinator.
|
|
2
|
+
|
|
3
|
+
Wraps an ``ExitStack`` plus signal handlers (SIGINT/SIGTERM on POSIX,
|
|
4
|
+
SIGINT only on Windows — Windows doesn't deliver SIGTERM to console apps).
|
|
5
|
+
The coordinator pushes a ``compose down -v`` callback for each stack as
|
|
6
|
+
soon as ``compose up`` is invoked. Any of:
|
|
7
|
+
|
|
8
|
+
- normal completion (queue drained, all workers exited 0)
|
|
9
|
+
- user Ctrl-C
|
|
10
|
+
- uncaught exception in coordinator
|
|
11
|
+
- SIGTERM from outside
|
|
12
|
+
|
|
13
|
+
…triggers ``ExitStack.close()`` which fires every registered teardown in
|
|
14
|
+
LIFO order. This is the single place that guarantees no orphaned
|
|
15
|
+
containers, networks, or volumes after a parallel run.
|
|
16
|
+
|
|
17
|
+
The Ctrl-C path raises ``KeyboardInterrupt`` after first calling the
|
|
18
|
+
default signal handler, so subsequent Ctrl-C presses still work normally
|
|
19
|
+
(double Ctrl-C escape hatch — useful if a teardown itself hangs).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import contextlib
|
|
25
|
+
import signal
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Callable
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CoordinatorLifecycle:
|
|
31
|
+
"""Context manager: ``with CoordinatorLifecycle() as lc: lc.on_teardown(fn)``.
|
|
32
|
+
|
|
33
|
+
Restores the previous signal handlers on exit so this doesn't leak signal
|
|
34
|
+
state to a calling process.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self._stack = contextlib.ExitStack()
|
|
39
|
+
self._previous_handlers: dict[int, Callable | int | None] = {}
|
|
40
|
+
self._signals_to_handle: list[int] = [signal.SIGINT]
|
|
41
|
+
if hasattr(signal, "SIGTERM") and sys.platform != "win32":
|
|
42
|
+
# Windows console apps don't receive SIGTERM in the usual sense;
|
|
43
|
+
# skip it to avoid OSError on signal.signal().
|
|
44
|
+
self._signals_to_handle.append(signal.SIGTERM)
|
|
45
|
+
|
|
46
|
+
def __enter__(self) -> "CoordinatorLifecycle":
|
|
47
|
+
self._stack.__enter__()
|
|
48
|
+
for sig in self._signals_to_handle:
|
|
49
|
+
try:
|
|
50
|
+
self._previous_handlers[sig] = signal.getsignal(sig)
|
|
51
|
+
signal.signal(sig, self._on_signal)
|
|
52
|
+
except (ValueError, OSError):
|
|
53
|
+
# signal.signal raises ValueError off the main thread; OSError
|
|
54
|
+
# for unsupported signals on the platform. Either way, skip.
|
|
55
|
+
pass
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __exit__(self, exc_type, exc, tb):
|
|
59
|
+
# Restore signal handlers BEFORE running teardown so a Ctrl-C during
|
|
60
|
+
# teardown gets handled by the default Python interrupt logic.
|
|
61
|
+
for sig, prev in self._previous_handlers.items():
|
|
62
|
+
try:
|
|
63
|
+
signal.signal(sig, prev)
|
|
64
|
+
except (ValueError, OSError):
|
|
65
|
+
pass
|
|
66
|
+
return self._stack.__exit__(exc_type, exc, tb)
|
|
67
|
+
|
|
68
|
+
def on_teardown(self, fn: Callable[[], None]) -> None:
|
|
69
|
+
"""Register a callback to run on context exit (LIFO order)."""
|
|
70
|
+
self._stack.callback(self._safe_call, fn)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _safe_call(fn: Callable[[], None]) -> None:
|
|
74
|
+
"""Run a teardown callback; never let an exception escape and abort
|
|
75
|
+
subsequent teardowns. Logs to stderr instead."""
|
|
76
|
+
try:
|
|
77
|
+
fn()
|
|
78
|
+
except Exception as e: # noqa: BLE001 — teardown must be best-effort
|
|
79
|
+
print(f"steppy teardown error (continuing): {e}", file=sys.stderr)
|
|
80
|
+
|
|
81
|
+
def _on_signal(self, signum, frame):
|
|
82
|
+
"""Convert SIGINT/SIGTERM into KeyboardInterrupt so the coordinator's
|
|
83
|
+
outer try/except can react and ExitStack runs teardowns. Restores
|
|
84
|
+
the previous handler immediately so a second Ctrl-C uses the default
|
|
85
|
+
behavior (panic-exit if a teardown hangs)."""
|
|
86
|
+
try:
|
|
87
|
+
signal.signal(signum, self._previous_handlers.get(signum, signal.SIG_DFL))
|
|
88
|
+
except (ValueError, OSError):
|
|
89
|
+
pass
|
|
90
|
+
raise KeyboardInterrupt()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""In-network pre-authentication lock-wrapper for parallel mode.
|
|
2
|
+
|
|
3
|
+
Runs INSIDE a one-off ``steppy-preauth`` container. The coordinator renders one
|
|
4
|
+
such service per worker stack (so it can be gated, per-stack, before that
|
|
5
|
+
stack's ``steppy-worker`` claims a scenario), which means N containers would
|
|
6
|
+
otherwise all run the user's login command at once and race on the shared
|
|
7
|
+
``.auth`` cache. Instead they share ``/steppy/coord`` (the coordinator tmp dir,
|
|
8
|
+
bind-mounted into every stack) and use the SAME O_EXCL claim primitive as the
|
|
9
|
+
work queue (`_parallel/queue.py`) to elect exactly ONE winner:
|
|
10
|
+
|
|
11
|
+
* winner — runs the user's ``pre_auth.command``; on success drops a
|
|
12
|
+
``preauth.done`` sentinel, on failure a ``preauth.failed`` one.
|
|
13
|
+
* losers — block until they see the winner's sentinel, then exit with the
|
|
14
|
+
matching code (0 on done, 1 on failed / timeout).
|
|
15
|
+
|
|
16
|
+
So the login happens once per run even though every stack starts a preauth
|
|
17
|
+
container. The sentinels live in the per-run coord dir, so a *new* run re-runs
|
|
18
|
+
the command (inter-run freshness is the command's own concern — e.g. the
|
|
19
|
+
capture script skips roles whose session is still fresh).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
LOCK_NAME = "preauth.lock"
|
|
32
|
+
DONE_NAME = "preauth.done"
|
|
33
|
+
FAILED_NAME = "preauth.failed"
|
|
34
|
+
_POLL_INTERVAL = 0.5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_preauth(
|
|
38
|
+
command: list[str],
|
|
39
|
+
*,
|
|
40
|
+
timeout: int,
|
|
41
|
+
coord_dir: Path | None = None,
|
|
42
|
+
) -> int:
|
|
43
|
+
"""Elect one winner (O_EXCL) to run ``command``; losers wait for its
|
|
44
|
+
sentinel. Returns a process exit code (0 = auth ready for this stack).
|
|
45
|
+
|
|
46
|
+
``coord_dir`` defaults to ``$STEPPY_COORD_DIR`` (``/steppy/coord`` in the
|
|
47
|
+
container). ``command`` is the argv to execute (a plain shell string is
|
|
48
|
+
wrapped as ``sh -c`` by the caller before it reaches here).
|
|
49
|
+
"""
|
|
50
|
+
if not command:
|
|
51
|
+
print("[preauth] no command given — nothing to do", file=sys.stderr, flush=True)
|
|
52
|
+
return 2
|
|
53
|
+
|
|
54
|
+
coord = Path(coord_dir) if coord_dir is not None else Path(os.environ.get("STEPPY_COORD_DIR", "/steppy/coord"))
|
|
55
|
+
coord.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
lock_path = coord / LOCK_NAME
|
|
57
|
+
done_path = coord / DONE_NAME
|
|
58
|
+
failed_path = coord / FAILED_NAME
|
|
59
|
+
|
|
60
|
+
# A previous stack already settled auth this run — short-circuit.
|
|
61
|
+
if done_path.exists():
|
|
62
|
+
return 0
|
|
63
|
+
if failed_path.exists():
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
fd = os.open(str(lock_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
|
68
|
+
except FileExistsError:
|
|
69
|
+
# Another stack is (or was) the winner — wait for its verdict.
|
|
70
|
+
return _wait_for_sentinel(done_path, failed_path, timeout)
|
|
71
|
+
|
|
72
|
+
# We won the claim: run the command exactly once for this run.
|
|
73
|
+
try:
|
|
74
|
+
os.write(fd, str(os.getpid()).encode("utf-8"))
|
|
75
|
+
finally:
|
|
76
|
+
os.close(fd)
|
|
77
|
+
|
|
78
|
+
print(f"[preauth] winner — running: {' '.join(command)}", flush=True)
|
|
79
|
+
try:
|
|
80
|
+
code = subprocess.run(command, timeout=timeout).returncode
|
|
81
|
+
except subprocess.TimeoutExpired:
|
|
82
|
+
print(f"[preauth] command timed out after {timeout}s", file=sys.stderr, flush=True)
|
|
83
|
+
code = 124
|
|
84
|
+
except FileNotFoundError as e:
|
|
85
|
+
print(f"[preauth] command not found: {e}", file=sys.stderr, flush=True)
|
|
86
|
+
code = 127
|
|
87
|
+
|
|
88
|
+
if code == 0:
|
|
89
|
+
done_path.write_text("ok", encoding="utf-8")
|
|
90
|
+
print("[preauth] done — auth cache is ready", flush=True)
|
|
91
|
+
else:
|
|
92
|
+
failed_path.write_text(str(code), encoding="utf-8")
|
|
93
|
+
print(f"[preauth] FAILED (exit {code})", file=sys.stderr, flush=True)
|
|
94
|
+
return code
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _wait_for_sentinel(done_path: Path, failed_path: Path, timeout: int) -> int:
|
|
98
|
+
"""Loser path: block until the winner signals done/failed, or timeout."""
|
|
99
|
+
print("[preauth] another stack is authenticating; waiting…", flush=True)
|
|
100
|
+
deadline = time.monotonic() + max(1, timeout)
|
|
101
|
+
while time.monotonic() < deadline:
|
|
102
|
+
if done_path.exists():
|
|
103
|
+
return 0
|
|
104
|
+
if failed_path.exists():
|
|
105
|
+
print("[preauth] auth winner reported failure", file=sys.stderr, flush=True)
|
|
106
|
+
return 1
|
|
107
|
+
time.sleep(_POLL_INTERVAL)
|
|
108
|
+
print("[preauth] timed out waiting for the auth winner", file=sys.stderr, flush=True)
|
|
109
|
+
return 1
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Tail each worker container's stdout and dispatch lines to the bus.
|
|
2
|
+
|
|
3
|
+
For each Compose project, a thread runs
|
|
4
|
+
``docker compose -p <project> logs -f --no-color steppy-worker`` and
|
|
5
|
+
feeds every line into the shared :class:`EventBus`. The bus parses
|
|
6
|
+
``STEPPY-EVT::*`` lines into typed events; everything else falls
|
|
7
|
+
through to the raw-log channel (printed when ``--verbose`` is set,
|
|
8
|
+
always written to a per-worker file for post-mortem).
|
|
9
|
+
|
|
10
|
+
A second thread per worker captures the full-stack logs (every service
|
|
11
|
+
in the project, not just steppy-worker) into a separate file so the
|
|
12
|
+
user can dig in after a failure.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import subprocess
|
|
18
|
+
import threading
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from .event_bus import EventBus
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class WorkerLogTailer:
|
|
28
|
+
"""Background thread tailing one worker's logs into the EventBus."""
|
|
29
|
+
|
|
30
|
+
project: str
|
|
31
|
+
worker_id: str
|
|
32
|
+
bus: EventBus
|
|
33
|
+
app_log_file: Path # full-stack logs go here for post-mortem
|
|
34
|
+
_proc: Optional[subprocess.Popen] = field(default=None, init=False, repr=False)
|
|
35
|
+
_thread: Optional[threading.Thread] = field(default=None, init=False, repr=False)
|
|
36
|
+
_stop: Optional[threading.Event] = field(default=None, init=False, repr=False)
|
|
37
|
+
|
|
38
|
+
def start(self) -> None:
|
|
39
|
+
self._stop = threading.Event()
|
|
40
|
+
self._thread = threading.Thread(target=self._run, name=f"log-{self.worker_id}", daemon=True)
|
|
41
|
+
self._thread.start()
|
|
42
|
+
# Also kick off a fire-and-forget capture of the entire stack's logs
|
|
43
|
+
# (worker + app) into a per-worker file so users have a post-mortem
|
|
44
|
+
# if the run fails.
|
|
45
|
+
self.app_log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
threading.Thread(target=self._capture_full_stack, name=f"log-full-{self.worker_id}", daemon=True).start()
|
|
47
|
+
|
|
48
|
+
def _run(self) -> None:
|
|
49
|
+
cmd = [
|
|
50
|
+
"docker",
|
|
51
|
+
"compose",
|
|
52
|
+
"-p",
|
|
53
|
+
self.project,
|
|
54
|
+
"logs",
|
|
55
|
+
"-f",
|
|
56
|
+
"--no-color",
|
|
57
|
+
"--no-log-prefix",
|
|
58
|
+
"steppy-worker",
|
|
59
|
+
]
|
|
60
|
+
try:
|
|
61
|
+
self._proc = subprocess.Popen(
|
|
62
|
+
cmd,
|
|
63
|
+
stdout=subprocess.PIPE,
|
|
64
|
+
stderr=subprocess.STDOUT,
|
|
65
|
+
text=True,
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
errors="replace",
|
|
68
|
+
bufsize=1,
|
|
69
|
+
)
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return
|
|
72
|
+
assert self._proc.stdout is not None
|
|
73
|
+
for line in self._proc.stdout:
|
|
74
|
+
if self._stop is not None and self._stop.is_set():
|
|
75
|
+
break
|
|
76
|
+
self.bus.feed_line(self.worker_id, line)
|
|
77
|
+
|
|
78
|
+
def _capture_full_stack(self) -> None:
|
|
79
|
+
"""Capture every service's logs into ``self.app_log_file`` for
|
|
80
|
+
post-mortem. Runs ``compose logs -f`` over the whole project (no
|
|
81
|
+
service filter) and writes raw output. Best-effort; if it fails
|
|
82
|
+
we just lose the post-mortem file, never the run."""
|
|
83
|
+
cmd = ["docker", "compose", "-p", self.project, "logs", "-f", "--no-color"]
|
|
84
|
+
try:
|
|
85
|
+
with self.app_log_file.open("w", encoding="utf-8") as f:
|
|
86
|
+
subprocess.run(
|
|
87
|
+
cmd, stdout=f, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", check=False
|
|
88
|
+
)
|
|
89
|
+
except (FileNotFoundError, OSError):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
def stop(self) -> None:
|
|
93
|
+
if self._stop is not None:
|
|
94
|
+
self._stop.set()
|
|
95
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
96
|
+
try:
|
|
97
|
+
self._proc.terminate()
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
if self._thread is not None:
|
|
101
|
+
self._thread.join(timeout=2)
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Filesystem work-stealing queue for parallel scenario execution.
|
|
2
|
+
|
|
3
|
+
Layout under the run's tmp dir (mounted into every worker at /steppy/coord):
|
|
4
|
+
queue/<task_id>.task — marker, written by the coordinator; never moved
|
|
5
|
+
claims/<task_id>.claim — atomic O_EXCL create by a worker; content = worker_id
|
|
6
|
+
results/<worker_id>/<slug>.json — per-SCENARIO results, written by workers
|
|
7
|
+
|
|
8
|
+
A task is one scenario by default, and ``task_id`` is then the bare slug and the
|
|
9
|
+
marker file is empty — exactly as it has always been. An **exclusion group**
|
|
10
|
+
(frontmatter ``exclusive: <name>``) collapses its members into a single task whose
|
|
11
|
+
id is the group name and whose marker carries one line of JSON naming its members;
|
|
12
|
+
the worker claiming it runs them sequentially, one pytest process each. That is the
|
|
13
|
+
whole mechanism: mutual exclusion is a property of how the queue was seeded, not a
|
|
14
|
+
run-time protocol, so there is no lock to leak and no worker ever waits on another.
|
|
15
|
+
|
|
16
|
+
Results, logs and JUnit stay keyed **per scenario** even for a batch, which is what
|
|
17
|
+
lets every downstream consumer (orphan detection, retry classification, failure
|
|
18
|
+
artifacts, history, the renderer) keep its per-slug key unchanged.
|
|
19
|
+
|
|
20
|
+
Claiming is atomic via ``os.open(claim_path, O_WRONLY|O_CREAT|O_EXCL)``,
|
|
21
|
+
which is atomic on both POSIX and Windows (battle-tested mail-spool
|
|
22
|
+
pattern). When two workers race for the same task, exactly one O_EXCL
|
|
23
|
+
succeeds and the other gets ``FileExistsError`` and tries the next
|
|
24
|
+
candidate.
|
|
25
|
+
|
|
26
|
+
We chose O_EXCL over the rename-claim variant because ``os.rename`` on
|
|
27
|
+
Windows under heavy concurrency does not provide the source-side mutual
|
|
28
|
+
exclusion we need (observed ~20% double-claim rate in the test).
|
|
29
|
+
|
|
30
|
+
Workers loop:
|
|
31
|
+
|
|
32
|
+
while True:
|
|
33
|
+
task = queue.claim_task(worker_id)
|
|
34
|
+
if task is None:
|
|
35
|
+
break # queue empty
|
|
36
|
+
for slug in task.slugs: # exactly one, unless it is an exclusion group
|
|
37
|
+
result = run_scenario(slug)
|
|
38
|
+
queue.write_result(result)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Optional
|
|
49
|
+
|
|
50
|
+
from ..exclusive import Task
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
TASK_SUFFIX = ".task"
|
|
54
|
+
CLAIM_SUFFIX = ".claim"
|
|
55
|
+
RESULT_SUFFIX = ".json"
|
|
56
|
+
|
|
57
|
+
# Version stamped into a batch marker's payload. Ungrouped tasks carry no payload
|
|
58
|
+
# at all, so this only ever appears in a queue that declares an exclusion group.
|
|
59
|
+
TASK_PAYLOAD_VERSION = 1
|
|
60
|
+
|
|
61
|
+
# Worker key used to report a batch whose claim file was created but never written
|
|
62
|
+
# — a worker killed between the claim's ``os.open`` and its ``os.write``. Its real
|
|
63
|
+
# id is unrecoverable, and every member is unstarted by construction (the id is
|
|
64
|
+
# written before any scenario runs), so they are all orphans of nobody.
|
|
65
|
+
UNKNOWN_WORKER = "(unknown)"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TaskPayloadError(Exception):
|
|
69
|
+
"""A batch marker exists but could not be read or parsed.
|
|
70
|
+
|
|
71
|
+
The claim already succeeded when this is raised, so the task is claimed with no
|
|
72
|
+
results: its members surface as orphans and are retried. The worker reports it
|
|
73
|
+
and moves on rather than dying, because one unreadable marker must not cost the
|
|
74
|
+
rest of the queue.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, task_id: str, reason: str):
|
|
78
|
+
super().__init__(f"unreadable task payload for {task_id!r}: {reason}")
|
|
79
|
+
self.task_id = task_id
|
|
80
|
+
self.reason = reason
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Task files are named ``NNNN-<slug>.task``. Claiming walks the directory in
|
|
84
|
+
# sorted order, so the prefix is what makes the queue honour the order the
|
|
85
|
+
# coordinator seeded rather than the alphabet — which lets it put the slowest
|
|
86
|
+
# scenarios first (LPT) so no worker is left running a 3-minute scenario alone
|
|
87
|
+
# after everyone else has finished. Claim/result files stay keyed by the bare
|
|
88
|
+
# slug, so nothing downstream has to know about this.
|
|
89
|
+
_ORDER_PREFIX_RE = re.compile(r"^\d{4}-")
|
|
90
|
+
_ORDER_PREFIX_WIDTH = 4
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class ScenarioResult:
|
|
95
|
+
"""One scenario's outcome, written to results/<worker_id>/<slug>.json."""
|
|
96
|
+
|
|
97
|
+
slug: str
|
|
98
|
+
worker_id: str
|
|
99
|
+
exit_code: int
|
|
100
|
+
duration_s: float
|
|
101
|
+
started_at: float # epoch seconds
|
|
102
|
+
finished_at: float
|
|
103
|
+
|
|
104
|
+
def to_dict(self) -> dict:
|
|
105
|
+
return {
|
|
106
|
+
"slug": self.slug,
|
|
107
|
+
"worker_id": self.worker_id,
|
|
108
|
+
"exit_code": self.exit_code,
|
|
109
|
+
"duration_s": self.duration_s,
|
|
110
|
+
"started_at": self.started_at,
|
|
111
|
+
"finished_at": self.finished_at,
|
|
112
|
+
"passed": self.exit_code == 0,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Queue:
|
|
117
|
+
"""Filesystem-backed work-stealing queue.
|
|
118
|
+
|
|
119
|
+
The coordinator instantiates one Queue, calls ``seed(slugs)`` to populate
|
|
120
|
+
queue/, then bind-mounts the parent dir into every worker container.
|
|
121
|
+
Each worker instantiates a Queue against the in-container path and
|
|
122
|
+
loops on ``claim()``. queue/, claims/, results/ are siblings under
|
|
123
|
+
``root`` on both sides — the coordinator writes to host:<tmp>/queue/,
|
|
124
|
+
the worker sees the same files at /steppy/coord/queue/.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, root: Path):
|
|
128
|
+
self.root = Path(root)
|
|
129
|
+
self.queue_dir = self.root / "queue"
|
|
130
|
+
self.claims_dir = self.root / "claims"
|
|
131
|
+
self.results_dir = self.root / "results"
|
|
132
|
+
# task_id -> member slugs, for the batches this Queue seeded. Held as
|
|
133
|
+
# instance state rather than passed to each reader on purpose: orphan
|
|
134
|
+
# detection has three call sites, and a reader that forgot the map would
|
|
135
|
+
# report a group NAME as a failed scenario and retry a test file that does
|
|
136
|
+
# not exist. State cannot be forgotten; a parameter can.
|
|
137
|
+
self._task_slugs: dict[str, list[str]] = {}
|
|
138
|
+
|
|
139
|
+
def init_dirs(self) -> None:
|
|
140
|
+
"""Create the queue/claims/results directory skeleton."""
|
|
141
|
+
self.queue_dir.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
self.claims_dir.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
self.results_dir.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
|
|
145
|
+
def seed(self, slugs: list[str], *, batches: Optional[dict[str, list[str]]] = None) -> None:
|
|
146
|
+
"""Coordinator-only: write one task file per queue entry.
|
|
147
|
+
|
|
148
|
+
``slugs`` is a list of **task ids** — bare scenario slugs unless an entry
|
|
149
|
+
names an exclusion group. Task files carry a zero-padded ordinal prefix so
|
|
150
|
+
``claim()``'s sorted walk hands them out in **the order given**, not
|
|
151
|
+
alphabetically. The coordinator uses that to seed longest-first from
|
|
152
|
+
recorded durations; callers that don't care can keep passing any order.
|
|
153
|
+
|
|
154
|
+
``batches`` maps a task id to its member slugs and is written into the
|
|
155
|
+
marker as one line of JSON. Ids absent from it keep today's empty
|
|
156
|
+
``touch()``, so a run with no exclusion group produces a byte-identical
|
|
157
|
+
queue directory. Callers pass ``batches`` only when it is non-empty.
|
|
158
|
+
"""
|
|
159
|
+
self.init_dirs()
|
|
160
|
+
batches = batches or {}
|
|
161
|
+
self._task_slugs = {task_id: list(members) for task_id, members in batches.items()}
|
|
162
|
+
for index, task_id in enumerate(slugs):
|
|
163
|
+
path = self.queue_dir / f"{index:0{_ORDER_PREFIX_WIDTH}d}-{task_id}{TASK_SUFFIX}"
|
|
164
|
+
members = batches.get(task_id)
|
|
165
|
+
if members:
|
|
166
|
+
payload = {"v": TASK_PAYLOAD_VERSION, "group": task_id, "slugs": list(members)}
|
|
167
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
168
|
+
else:
|
|
169
|
+
path.touch()
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _slug_from_task(name: str) -> str:
|
|
173
|
+
"""``0007-checkout.task`` → ``checkout``. Tolerates un-prefixed names so
|
|
174
|
+
a queue dir seeded by an older steppy still drains.
|
|
175
|
+
|
|
176
|
+
Returns the *task id*, which is a scenario slug unless the entry is a
|
|
177
|
+
batch, in which case it is the exclusion group's name."""
|
|
178
|
+
stem = name[: -len(TASK_SUFFIX)] if name.endswith(TASK_SUFFIX) else name
|
|
179
|
+
return _ORDER_PREFIX_RE.sub("", stem, count=1)
|
|
180
|
+
|
|
181
|
+
def _read_task(self, entry: Path, task_id: str) -> Task:
|
|
182
|
+
"""Turn a claimed marker file into a :class:`Task`.
|
|
183
|
+
|
|
184
|
+
Read **after** the claim succeeds: reading first would waste a read on
|
|
185
|
+
every lost race, and the file is written once at seed time and never
|
|
186
|
+
mutated, so reading it later is race-free.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
raw = entry.read_text(encoding="utf-8").strip()
|
|
190
|
+
except OSError as e:
|
|
191
|
+
raise TaskPayloadError(task_id, str(e))
|
|
192
|
+
if not raw:
|
|
193
|
+
return Task(task_id=task_id, slugs=(task_id,), group=None)
|
|
194
|
+
try:
|
|
195
|
+
payload = json.loads(raw)
|
|
196
|
+
members = [str(s) for s in payload["slugs"]]
|
|
197
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
198
|
+
raise TaskPayloadError(task_id, str(e))
|
|
199
|
+
if not members:
|
|
200
|
+
raise TaskPayloadError(task_id, "payload declares no scenarios")
|
|
201
|
+
return Task(task_id=task_id, slugs=tuple(members), group=str(payload.get("group") or task_id))
|
|
202
|
+
|
|
203
|
+
def claim_task(self, worker_id: str) -> Optional[Task]:
|
|
204
|
+
"""Worker-only: atomically claim the next pending task via O_EXCL on
|
|
205
|
+
a per-task claim file. Returns the :class:`Task`, or None if the
|
|
206
|
+
queue is empty for this worker.
|
|
207
|
+
|
|
208
|
+
Tasks are seeded once by the coordinator before any worker runs and
|
|
209
|
+
are never removed (task files stay put for orphan detection). A
|
|
210
|
+
single pass through the snapshot is therefore sufficient: if every
|
|
211
|
+
candidate raises ``FileExistsError`` then every queued task has
|
|
212
|
+
already been claimed by another worker and there is nothing for us
|
|
213
|
+
to do — return ``None`` so the caller exits.
|
|
214
|
+
|
|
215
|
+
Raises :class:`TaskPayloadError` when a batch marker cannot be read; the
|
|
216
|
+
claim has already succeeded by then, so the caller reports it and moves on
|
|
217
|
+
and the members surface as orphans.
|
|
218
|
+
"""
|
|
219
|
+
self.init_dirs()
|
|
220
|
+
try:
|
|
221
|
+
candidates = sorted(e for e in self.queue_dir.iterdir() if e.name.endswith(TASK_SUFFIX))
|
|
222
|
+
except FileNotFoundError:
|
|
223
|
+
return None
|
|
224
|
+
for entry in candidates:
|
|
225
|
+
task_id = self._slug_from_task(entry.name)
|
|
226
|
+
claim_path = self.claims_dir / f"{task_id}{CLAIM_SUFFIX}"
|
|
227
|
+
try:
|
|
228
|
+
fd = os.open(
|
|
229
|
+
str(claim_path),
|
|
230
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
231
|
+
)
|
|
232
|
+
except FileExistsError:
|
|
233
|
+
continue # another worker got it; try the next candidate
|
|
234
|
+
try:
|
|
235
|
+
os.write(fd, worker_id.encode("utf-8"))
|
|
236
|
+
finally:
|
|
237
|
+
os.close(fd)
|
|
238
|
+
return self._read_task(entry, task_id)
|
|
239
|
+
return None # every candidate already claimed → drained for us
|
|
240
|
+
|
|
241
|
+
def claim(self, worker_id: str) -> Optional[str]:
|
|
242
|
+
"""Claim the next pending task and return its **task id**.
|
|
243
|
+
|
|
244
|
+
A thin wrapper over :meth:`claim_task` so there is one implementation of
|
|
245
|
+
the claim protocol and no drift between them.
|
|
246
|
+
"""
|
|
247
|
+
task = self.claim_task(worker_id)
|
|
248
|
+
return task.task_id if task is not None else None
|
|
249
|
+
|
|
250
|
+
def write_result(self, result: ScenarioResult) -> None:
|
|
251
|
+
"""Worker-only: persist the result to results/<worker_id>/<slug>.json."""
|
|
252
|
+
worker_results_dir = self.results_dir / result.worker_id
|
|
253
|
+
worker_results_dir.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
result_path = worker_results_dir / f"{result.slug}{RESULT_SUFFIX}"
|
|
255
|
+
result_path.write_text(json.dumps(result.to_dict()), encoding="utf-8")
|
|
256
|
+
|
|
257
|
+
def unclaimed_tasks(self) -> set[str]:
|
|
258
|
+
"""Task ids that were queued and never claimed by anybody.
|
|
259
|
+
|
|
260
|
+
Reachable only when every worker died with work left — but nothing else in
|
|
261
|
+
the system observes it: ``orphaned_running_tasks`` walks ``claims/`` and
|
|
262
|
+
``collect_results`` walks ``results/``, so a task with neither file is
|
|
263
|
+
invisible to both. Without this the run can report success having silently
|
|
264
|
+
executed nothing.
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
queued = {self._slug_from_task(p.name) for p in self.queue_dir.iterdir() if p.suffix == TASK_SUFFIX}
|
|
268
|
+
except FileNotFoundError:
|
|
269
|
+
return set()
|
|
270
|
+
try:
|
|
271
|
+
claimed = {p.stem for p in self.claims_dir.iterdir() if p.suffix == CLAIM_SUFFIX}
|
|
272
|
+
except FileNotFoundError:
|
|
273
|
+
claimed = set()
|
|
274
|
+
return queued - claimed
|
|
275
|
+
|
|
276
|
+
def _reported_slugs(self) -> set[str]:
|
|
277
|
+
"""Every scenario that produced a result, by filename — no JSON parsing."""
|
|
278
|
+
if not self.results_dir.exists():
|
|
279
|
+
return set()
|
|
280
|
+
return {p.stem for p in self.results_dir.glob(f"*/*{RESULT_SUFFIX}")}
|
|
281
|
+
|
|
282
|
+
def unclaimed_slugs(self) -> list[str]:
|
|
283
|
+
"""Scenarios with no evidence they ever ran.
|
|
284
|
+
|
|
285
|
+
:meth:`unclaimed_tasks` expanded through the batch map, minus anything
|
|
286
|
+
that reported a result. A result implies a claim in the real protocol
|
|
287
|
+
(the claim file is created first), so subtracting them changes nothing
|
|
288
|
+
about a live run — but it makes this incapable of a false positive, and
|
|
289
|
+
this set forces the run to fail.
|
|
290
|
+
"""
|
|
291
|
+
reported = self._reported_slugs()
|
|
292
|
+
out: list[str] = []
|
|
293
|
+
for task_id in sorted(self.unclaimed_tasks()):
|
|
294
|
+
out.extend(s for s in (self._task_slugs.get(task_id) or [task_id]) if s not in reported)
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
def is_empty(self) -> bool:
|
|
298
|
+
"""Coordinator-only: True when no pending tasks remain (every queued
|
|
299
|
+
task has at least been claimed)."""
|
|
300
|
+
return not self.unclaimed_tasks()
|
|
301
|
+
|
|
302
|
+
def collect_results(self) -> list[ScenarioResult]:
|
|
303
|
+
"""Coordinator-only: read all results written so far."""
|
|
304
|
+
out: list[ScenarioResult] = []
|
|
305
|
+
if not self.results_dir.exists():
|
|
306
|
+
return out
|
|
307
|
+
for worker_dir in self.results_dir.iterdir():
|
|
308
|
+
if not worker_dir.is_dir():
|
|
309
|
+
continue
|
|
310
|
+
for result_file in worker_dir.glob(f"*{RESULT_SUFFIX}"):
|
|
311
|
+
try:
|
|
312
|
+
data = json.loads(result_file.read_text(encoding="utf-8"))
|
|
313
|
+
except (json.JSONDecodeError, OSError):
|
|
314
|
+
continue
|
|
315
|
+
out.append(
|
|
316
|
+
ScenarioResult(
|
|
317
|
+
slug=data["slug"],
|
|
318
|
+
worker_id=data["worker_id"],
|
|
319
|
+
exit_code=int(data["exit_code"]),
|
|
320
|
+
duration_s=float(data["duration_s"]),
|
|
321
|
+
started_at=float(data["started_at"]),
|
|
322
|
+
finished_at=float(data["finished_at"]),
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
def _assert_batch_map_present(self) -> None:
|
|
328
|
+
"""Fail loudly if this Queue holds batch markers it cannot interpret.
|
|
329
|
+
|
|
330
|
+
A reader that expands claim stems without the map would report an exclusion
|
|
331
|
+
group's NAME as a failed scenario, retry a test file that does not exist,
|
|
332
|
+
and never retry the group's real members. That is a silent, plausible-looking
|
|
333
|
+
wrong answer, so turn the only way it can arise — querying a Queue instance
|
|
334
|
+
that did not seed this directory — into an immediate error.
|
|
335
|
+
"""
|
|
336
|
+
if self._task_slugs:
|
|
337
|
+
return
|
|
338
|
+
try:
|
|
339
|
+
entries = [p for p in self.queue_dir.iterdir() if p.name.endswith(TASK_SUFFIX)]
|
|
340
|
+
except FileNotFoundError:
|
|
341
|
+
return
|
|
342
|
+
batched = sorted(self._slug_from_task(p.name) for p in entries if p.stat().st_size > 0)
|
|
343
|
+
if batched:
|
|
344
|
+
raise RuntimeError(
|
|
345
|
+
f"queue at {self.root} holds exclusion-group task(s) {batched} but this "
|
|
346
|
+
"Queue instance has no batch map. Read it through the Queue that seeded "
|
|
347
|
+
"it, or re-seed with `batches=`."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def orphaned_running_tasks(self) -> dict[str, list[str]]:
|
|
351
|
+
"""Coordinator-only: scenarios claimed but never reported (worker crashed
|
|
352
|
+
before writing the result file). Returns ``{worker_id: [slug, ...]}``.
|
|
353
|
+
|
|
354
|
+
Always **scenario slugs**, never task ids: a claimed batch is expanded
|
|
355
|
+
through the map, so a worker that died after two of three members still
|
|
356
|
+
reports exactly the one that did not finish. A stem with no map entry is a
|
|
357
|
+
plain scenario and takes the original path verbatim.
|
|
358
|
+
|
|
359
|
+
v0.1: these are reported as failures, not requeued.
|
|
360
|
+
"""
|
|
361
|
+
orphans: dict[str, list[str]] = {}
|
|
362
|
+
if not self.claims_dir.exists():
|
|
363
|
+
return orphans
|
|
364
|
+
self._assert_batch_map_present()
|
|
365
|
+
for claim_file in self.claims_dir.glob(f"*{CLAIM_SUFFIX}"):
|
|
366
|
+
task_id = claim_file.stem
|
|
367
|
+
members = self._task_slugs.get(task_id)
|
|
368
|
+
try:
|
|
369
|
+
worker_id = claim_file.read_text(encoding="utf-8").strip()
|
|
370
|
+
except OSError:
|
|
371
|
+
continue
|
|
372
|
+
if not worker_id:
|
|
373
|
+
# Killed between the claim's create and its write. For a single
|
|
374
|
+
# scenario that stays today's skip; for a batch, skipping would
|
|
375
|
+
# lose every member in silence — and the owner id is written
|
|
376
|
+
# before any scenario runs, so none of them started.
|
|
377
|
+
if members:
|
|
378
|
+
orphans.setdefault(UNKNOWN_WORKER, []).extend(members)
|
|
379
|
+
continue
|
|
380
|
+
for slug in members if members else [task_id]:
|
|
381
|
+
result_path = self.results_dir / worker_id / f"{slug}{RESULT_SUFFIX}"
|
|
382
|
+
if not result_path.exists():
|
|
383
|
+
orphans.setdefault(worker_id, []).append(slug)
|
|
384
|
+
# Keep slugs sorted for deterministic test output.
|
|
385
|
+
return {wid: sorted(slugs) for wid, slugs in orphans.items()}
|