"""サーバの状況(tmux セッション・GPU・計算プロセス)の解析と,GPU の選択(純関数).
ssh を呼ぶ部分は CLI 側にあり,ここは ssh の出力文字列を受け取って解釈するだけにする
(テストで ssh・nvidia-smi をモックせず,出力の文字列で固定できるようにするため).
判定と選択の規則は SPEC_03「GPU の空き判定と選択」に従う.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from enum import StrEnum
from tias_judge_model.remote.commands import (
PROBE_APPS_MARKER,
PROBE_APPS_RC_PREFIX,
PROBE_END_MARKER,
PROBE_GPU_RC_PREFIX,
PROBE_GPUS_MARKER,
PROBE_JOB_PREFIX,
PROBE_OTHER_PREFIX,
PROBE_OWNERS_MARKER,
PROBE_PMON_MARKER,
PROBE_SAMPLES_MARKER,
PROBE_SESSIONS_MARKER,
)
from tias_judge_model.remote.config import GpuFreeThreshold, ServerConfig
from tias_judge_model.remote.observe import (
GpuSample,
GpuUsage,
UsagePattern,
classify_usage_pattern,
parse_gpu_samples,
parse_pmon,
summarize_samples,
)
# nvidia-smi --query-gpu の列数(index,uuid,name,memory.used,memory.total,utilization.gpu)
_GPU_COLUMNS = 6
# nvidia-smi --query-compute-apps の列数(gpu_uuid,pid,used_memory)
_APP_COLUMNS = 3
class GpuState(StrEnum):
"""GPU の状態(選ぶ優先順は UNUSED → SHARED.BUSY・UNKNOWN は選ばない)."""
UNUSED = "未使用"
SHARED = "空き(利用者あり)"
BUSY = "使用中"
UNKNOWN = "状態不明"
# 自動選択で順に探す状態(未使用がサーバの優先順位より優先される)
SELECTABLE_STATES = (GpuState.UNUSED, GpuState.SHARED)
@dataclass(frozen=True)
class GpuProcess:
"""GPU 上の計算プロセス 1 件.
Attributes:
pid: プロセス ID.
used_memory_mib: 使用メモリ(MiB).取れなければ None.
user: 所有ユーザー.ps で取れなければ None.
elapsed_sec: 起動からの経過秒数.ps で取れなければ None.
max_sm_percent: 観測中の SM 使用率の最大(pmon).取れなければ None.
"""
pid: int
used_memory_mib: int | None
user: str | None
elapsed_sec: int | None = None
max_sm_percent: int | None = None
@dataclass(frozen=True)
class ProcessOwner:
"""ps で取ったプロセスの所有者と経過時間.
Attributes:
user: 所有ユーザー.
elapsed_sec: 起動からの経過秒数.取れなければ None.
"""
user: str
elapsed_sec: int | None
@dataclass(frozen=True)
class GpuInfo:
"""GPU 1 基の状況.
Attributes:
index: GPU 番号.解釈できなければ None.
uuid: GPU の UUID(計算プロセスとの対応付けに使う).解釈できない行では空文字.
name: GPU の名前(解釈できない行では空文字).
memory_used_mib: 使用中メモリ(MiB.観測前の瞬間値).解釈できなければ None.
memory_total_mib: 総メモリ(MiB).解釈できなければ None.
utilization_percent: 使用率(%.観測前の瞬間値).解釈できなければ None.
state: 判定の結果.
processes: この GPU 上の計算プロセス.
processes_known: 計算プロセスの一覧が取れたか(False なら利用者不明).
usage: 観測の要約(観測しないときは瞬間値 1 つの要約).サンプルが無ければ None.
pattern: 使い方の型(表示専用).決められなければ None.
"""
index: int | None
uuid: str
name: str
memory_used_mib: int | None
memory_total_mib: int | None
utilization_percent: int | None
state: GpuState
processes: tuple[GpuProcess, ...] = ()
processes_known: bool = False
usage: GpuUsage | None = None
pattern: UsagePattern | None = None
@dataclass(frozen=True)
class ProbeOutput:
"""probe コマンド(`build_probe_command`)の出力を解釈した結果.
Attributes:
jobs: このツールのジョブ(ジョブログがある tmux セッション)の名前.
other_sessions: それ以外の tmux セッションの名前.
gpu_query_ok: GPU の状態の取得が成功したか.False なら GPU はすべて候補から外す.
processes_query_ok: 計算プロセスの一覧の取得・解釈が成功したか.False なら閾値内の
GPU はすべて「空き(利用者あり)」になる.
gpus: GPU の一覧(GPU の状態の取得が失敗したときは空).
observe_sec: 観測した秒数(0 なら瞬間値のみで判定した).
pmon_ok: プロセス別の観測(pmon)が得られたか(False なら表示を省くだけ).
"""
jobs: tuple[str, ...]
other_sessions: tuple[str, ...]
gpu_query_ok: bool
processes_query_ok: bool
gpus: tuple[GpuInfo, ...]
observe_sec: int = 0
pmon_ok: bool = False
@property
def sessions(self) -> tuple[str, ...]:
"""すべての tmux セッション名(ジョブとそれ以外)."""
return self.jobs + self.other_sessions
@dataclass(frozen=True)
class ServerProbe:
"""サーバ 1 台の状況.
Attributes:
server: サーバの設定.
output: probe の結果.接続できなかったときは None.
error: 接続できなかった理由(表示用.ssh の標準エラーの最終行等).
"""
server: ServerConfig
output: ProbeOutput | None
error: str = ""
@property
def reachable(self) -> bool:
"""接続できたか."""
return self.output is not None
@dataclass(frozen=True)
class GpuSelection:
"""run が使う GPU.
Attributes:
server: 選んだサーバ.
gpu_index: 選んだ GPU 番号.
state: 選んだ時点の GPU の状態.
gpu: 選んだ GPU の状況(利用者の警告表示用.状態を取れなかったときは None).
forced: 使用中・状態不明の GPU を `--force` で選んだか(警告を出す).
"""
server: ServerConfig
gpu_index: int
state: GpuState
gpu: GpuInfo | None = None
forced: bool = False
def classify_gpu(
memory_used_mib: int,
utilization_percent: int,
threshold: GpuFreeThreshold,
process_count: int | None,
) -> GpuState:
"""閾値と計算プロセスの数から GPU の状態を判定する.
観測するときは,使用中メモリ・使用率に**観測中の最大値**を渡す(一度でも閾値を超えた
GPU を使用中にするため).
Args:
memory_used_mib: 使用中メモリ(MiB.観測中の最大).
utilization_percent: 使用率(%.観測中の最大).
threshold: 空き判定の閾値.
process_count: 計算プロセスの数(所有者を問わない).一覧が取れなければ None.
Returns:
閾値を満たさなければ `BUSY`.閾値内で計算プロセスが 0 なら `UNUSED`,
1 つ以上または不明(None)なら `SHARED`(未使用と誤認しないため).
"""
within = (
memory_used_mib < threshold.max_memory_used_mib
and utilization_percent < threshold.max_utilization_percent
)
if not within:
return GpuState.BUSY
return GpuState.UNUSED if process_count == 0 else GpuState.SHARED
def _parse_int(text: str) -> int | None:
"""整数として解釈できれば返す(`[N/A]` 等は None)."""
try:
return int(text.strip())
except ValueError:
return None
def _is_valid_uuid(text: str) -> bool:
"""nvidia-smi の UUID として使える値か(空・`[N/A]` 等を除く)."""
return bool(text) and not text.startswith("[") and " " not in text
def parse_process_owners(text: str) -> dict[int, ProcessOwner]:
"""probe の所有者の節(`<pid> <ユーザー> <経過秒>` の行)を解釈する.
ユーザー名が無い行(ps で所有者を取れなかったプロセス)と解釈できない行は含めない
(所有者・経過時間は表示用で,判定には使わないため).
Args:
text: 所有者の節の出力.
Returns:
pid から所有者・経過秒数への対応.
"""
owners: dict[int, ProcessOwner] = {}
for line in text.splitlines():
tokens = line.split()
if len(tokens) < 2:
continue
pid = _parse_int(tokens[0])
if pid is None:
continue
elapsed = _parse_int(tokens[2]) if len(tokens) >= 3 else None
owners[pid] = ProcessOwner(tokens[1], elapsed)
return owners
def parse_compute_apps(
text: str,
owners: Mapping[int, ProcessOwner],
sm_peaks: Mapping[int, int] | None = None,
) -> dict[str, tuple[GpuProcess, ...]] | None:
"""`nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory` の出力を GPU ごとにまとめる.
Args:
text: nvidia-smi の標準出力(空なら計算プロセスは 0).
owners: pid から所有者・経過秒数への対応(`parse_process_owners`).
sm_peaks: pid から観測中の SM 使用率の最大への対応(`parse_pmon`).無ければ None.
Returns:
GPU の UUID から計算プロセスへの対応.1 行でも解釈できなければ None
(一覧を信用せず利用者不明として扱うため).
"""
apps: dict[str, list[GpuProcess]] = {}
for line in text.splitlines():
if not line.strip():
continue
columns = [column.strip() for column in line.split(",")]
if len(columns) != _APP_COLUMNS or not _is_valid_uuid(columns[0]):
return None
pid = _parse_int(columns[1])
if pid is None:
return None
owner = owners.get(pid)
process = GpuProcess(
pid=pid,
used_memory_mib=_parse_int(columns[2]),
user=owner.user if owner else None,
elapsed_sec=owner.elapsed_sec if owner else None,
max_sm_percent=sm_peaks.get(pid) if sm_peaks is not None else None,
)
apps.setdefault(columns[0], []).append(process)
return {uuid: tuple(processes) for uuid, processes in apps.items()}
def parse_nvidia_smi_line(
line: str,
threshold: GpuFreeThreshold,
processes: Mapping[str, Sequence[GpuProcess]] | None,
samples: Sequence[GpuSample] | None = None,
) -> GpuInfo:
"""`nvidia-smi --query-gpu`(`--format=csv,noheader,nounits`)の 1 行を解釈して判定する.
列数不足・数値でない値(`[N/A]` 等)の行は `UNKNOWN` とする.GPU 名にカンマを含む場合に
備え,先頭 2 列を index・uuid,末尾 3 列を数値として,その間を名前とする.
観測したとき(`samples` が None でない)は,観測中の最大値(とこの行の瞬間値の大きい方)で
閾値を判定し,サンプルが 1 つも無ければ `UNKNOWN` とする.観測しないとき(None)は
この行の瞬間値で判定する.
Args:
line: nvidia-smi の出力 1 行.
threshold: 空き判定の閾値.
processes: GPU の UUID から計算プロセスへの対応(`parse_compute_apps`).
計算プロセスの一覧が取れなかったときは None(閾値内なら `SHARED` になる).
samples: この GPU の観測サンプル.観測しないときは None.
Returns:
GPU の状況.
"""
columns = [column.strip() for column in line.split(",")]
if len(columns) < _GPU_COLUMNS:
index = _parse_int(columns[0]) if columns else None
return GpuInfo(index, "", "", None, None, None, GpuState.UNKNOWN)
index = _parse_int(columns[0])
uuid = columns[1]
name = ", ".join(columns[2:-3])
used, total, util = (_parse_int(value) for value in columns[-3:])
# UUID が取れない GPU は計算プロセスと対応付けられないので,利用者不明として扱う
known = processes is not None and _is_valid_uuid(uuid)
gpu_processes = tuple(processes.get(uuid, ())) if known and processes is not None else ()
count = len(gpu_processes) if known else None
if index is None or used is None or total is None or util is None:
return GpuInfo(index, uuid, name, used, total, util, GpuState.UNKNOWN, gpu_processes, known)
observed = samples is not None
usage = summarize_samples(samples if samples is not None else [GpuSample(util, used)])
if usage is None:
# 観測したのにサンプルが 1 つも得られない GPU は使わない
state = GpuState.UNKNOWN
pattern = None
else:
max_used = max(usage.max_memory_used_mib, used) if observed else used
max_util = max(usage.max_utilization_percent, util) if observed else util
state = classify_gpu(max_used, max_util, threshold, count)
pattern = classify_usage_pattern(usage, count)
return GpuInfo(
index, uuid, name, used, total, util, state, gpu_processes, known, usage, pattern
)
def parse_nvidia_smi_output(
text: str,
threshold: GpuFreeThreshold,
processes: Mapping[str, Sequence[GpuProcess]] | None,
samples: Mapping[int, Sequence[GpuSample]] | None = None,
) -> list[GpuInfo]:
"""`nvidia-smi --query-gpu` の出力全体を 1 行 1 GPU として解釈する(空行は無視する).
Args:
text: nvidia-smi の標準出力.
threshold: 空き判定の閾値.
processes: GPU の UUID から計算プロセスへの対応.一覧が取れなかったときは None.
samples: GPU 番号から観測サンプルへの対応(`parse_gpu_samples`).観測しないときは
None(瞬間値で判定する).観測したのに該当する GPU のサンプルが無ければ状態不明.
Returns:
出力順の GPU の一覧.
"""
gpus: list[GpuInfo] = []
for line in text.splitlines():
if not line.strip():
continue
gpu_samples: Sequence[GpuSample] | None = None
if samples is not None:
index = _parse_int(line.split(",", 1)[0])
gpu_samples = samples.get(index, []) if index is not None else []
gpus.append(parse_nvidia_smi_line(line, threshold, processes, gpu_samples))
return gpus
def parse_probe_output(text: str, threshold: GpuFreeThreshold) -> ProbeOutput:
"""probe コマンド(`build_probe_command`)の標準出力を解釈する.
目印より前の行(ログインシェルが出す挨拶等)は無視する.計算プロセスの節が欠けている・
終了コードが 0 でない・解釈できない場合は,計算プロセスの一覧を「取得できない」とする.
`threshold.observe_sec > 0` なら観測したものとして判定し,観測の節が欠けていれば
(サーバで一時ディレクトリを作れなかった等)各 GPU は状態不明になる.
Args:
text: probe コマンドの標準出力.
threshold: 空き判定の閾値と観測の設定(probe コマンドの組み立てに使ったもの).
Returns:
解釈した結果.
Raises:
ValueError: 必須の目印(セッション・GPU・GPU の終了コード・終端)がそろっていない
(出力が途中で切れた等)場合.
"""
section: str | None = None
jobs: list[str] = []
others: list[str] = []
lines: dict[str, list[str]] = {
"gpus": [],
"samples": [],
"pmon": [],
"apps": [],
"owners": [],
}
gpu_rc: int | None = None
apps_rc: int | None = None
seen_sessions = ended = False
for line in text.splitlines():
if line == PROBE_SESSIONS_MARKER:
section, seen_sessions = "sessions", True
elif line == PROBE_GPUS_MARKER:
section = "gpus"
elif line.startswith(PROBE_GPU_RC_PREFIX) and section == "gpus":
gpu_rc = _parse_int(line[len(PROBE_GPU_RC_PREFIX) :])
section = None
elif line == PROBE_SAMPLES_MARKER:
section = "samples"
elif line == PROBE_PMON_MARKER:
section = "pmon"
elif line == PROBE_APPS_MARKER:
section = "apps"
elif line.startswith(PROBE_APPS_RC_PREFIX) and section == "apps":
apps_rc = _parse_int(line[len(PROBE_APPS_RC_PREFIX) :])
section = None
elif line == PROBE_OWNERS_MARKER:
section = "owners"
elif line == PROBE_END_MARKER:
section, ended = None, True
elif section == "sessions":
if line.startswith(PROBE_JOB_PREFIX):
jobs.append(line[len(PROBE_JOB_PREFIX) :])
elif line.startswith(PROBE_OTHER_PREFIX):
others.append(line[len(PROBE_OTHER_PREFIX) :])
elif section in lines:
lines[section].append(line)
if not (seen_sessions and ended) or gpu_rc is None:
raise ValueError("サーバの状況の出力を解釈できない(目印が欠けている)")
texts = {name: "\n".join(section_lines) for name, section_lines in lines.items()}
gpu_ok = gpu_rc == 0
observed = threshold.observe_sec > 0
sm_peaks = parse_pmon(texts["pmon"]) if observed else None
processes = None
if gpu_ok and apps_rc == 0:
owners = parse_process_owners(texts["owners"])
processes = parse_compute_apps(texts["apps"], owners, sm_peaks)
samples = parse_gpu_samples(texts["samples"]) if observed else None
gpus = (
tuple(parse_nvidia_smi_output(texts["gpus"], threshold, processes, samples))
if gpu_ok
else ()
)
return ProbeOutput(
jobs=tuple(jobs),
other_sessions=tuple(others),
gpu_query_ok=gpu_ok,
processes_query_ok=processes is not None,
gpus=gpus,
observe_sec=threshold.observe_sec if observed else 0,
pmon_ok=sm_peaks is not None,
)
def _find_probe(probes: Sequence[ServerProbe], name: str) -> ServerProbe:
"""名前(大文字小文字を区別しない)でサーバの状況を探す."""
for probe in probes:
if probe.server.name.lower() == name.lower():
return probe
known = ", ".join(probe.server.name for probe in probes)
raise ValueError(f"サーバ {name!r} は設定に無い(設定済み: {known})")
def _select_explicit(probe: ServerProbe, gpu_index: int, *, force: bool) -> GpuSelection:
"""`--server --gpu` で指定された GPU を検証して返す."""
output = probe.output
if output is None:
raise RuntimeError("接続できないサーバの GPU を検証しようとした")
gpu = next((g for g in output.gpus if g.index == gpu_index), None)
if gpu is None:
# 一覧を正しく取れていて該当が無いなら,存在しない GPU の指定である
if output.gpu_query_ok and all(g.index is not None for g in output.gpus):
raise ValueError(f"{probe.server.name} に GPU {gpu_index} は無い")
state = GpuState.UNKNOWN
else:
state = gpu.state
if state in SELECTABLE_STATES:
return GpuSelection(probe.server, gpu_index, state, gpu)
if not force:
raise ValueError(
f"{probe.server.name} の GPU {gpu_index} は{state}である"
"(使うなら --force を付ける.他のユーザーが使用中の可能性がある)"
)
return GpuSelection(probe.server, gpu_index, state, gpu, forced=True)
def check_selection_options(*, server_name: str | None, gpu_index: int | None, force: bool) -> None:
"""GPU 指定の組み合わせを検証する(サーバへ問い合わせる前に弾けるよう独立させている).
Args:
server_name: `--server` の値.
gpu_index: `--gpu` の値.
force: `--force` の有無.
Raises:
ValueError: `--gpu` だけの指定,`--gpu` 無しの `--force`,負の GPU 番号の場合.
"""
if gpu_index is not None and server_name is None:
raise ValueError("--gpu を指定するときは --server も指定する(どのサーバか曖昧になるため)")
if force and gpu_index is None:
raise ValueError("--force は --server と --gpu で GPU を指定したときだけ使える")
if gpu_index is not None and gpu_index < 0:
raise ValueError(f"GPU 番号は 0 以上: {gpu_index}")
def check_cpu_options(*, gpu_index: int | None, force: bool, observe_sec: int | None) -> None:
"""`--cpu` と GPU 関連のオプションの組み合わせを検証する(ssh より前に弾くため独立させている).
Args:
gpu_index: `--gpu` の値.
force: `--force` の有無.
observe_sec: `--observe` の値.
Raises:
ValueError: `--cpu` と `--gpu`・`--force`・`--observe` のいずれかを同時に指定した場合.
"""
conflicts = [
option
for option, given in (
("--gpu", gpu_index is not None),
("--force", force),
("--observe", observe_sec is not None),
)
if given
]
if conflicts:
raise ValueError(
f"--cpu は {'・'.join(conflicts)} と同時に指定できない"
"(--cpu では GPU を観測・選択しない)"
)
def select_cpu_server(
probes: Sequence[ServerProbe],
*,
server_name: str | None = None,
include_not_ready: bool = False,
) -> ServerConfig | None:
"""`run --cpu`(GPU を使わないジョブ)を実行するサーバを選ぶ(SPEC_03「run」).
`server_name` を指定したらそのサーバ.省略時は候補サーバ(`data_ready` のもの.
`include_not_ready` なら全サーバ)を priority 昇順(同値は `probes` の順)に見て,最初に
接続できたものを選ぶ.GPU の状態は見ない.
Args:
probes: 各サーバの状況(設定の記載順で渡す.同じ priority の順位に使う).
server_name: `--server` の値.
include_not_ready: `data_ready: false` のサーバも候補にする(`--no-data`).
Returns:
選んだサーバ.自動選択で接続できる候補サーバが無ければ None.
Raises:
ValueError: 指定したサーバが設定に無い・データ未配置(`include_not_ready` 無し)・
接続不可の場合.
"""
if server_name is not None:
probe = _find_probe(probes, server_name)
if not probe.server.data_ready and not include_not_ready:
raise ValueError(
f"{probe.server.name} は臨床データが未配置(data_ready: false)である"
"(データを使わないジョブなら --no-data を付ける)"
)
if not probe.reachable:
raise ValueError(f"{probe.server.name} に接続できない({probe.error})")
return probe.server
candidates = sorted(
(p for p in probes if p.reachable and (include_not_ready or p.server.data_ready)),
key=lambda p: p.server.priority,
)
return candidates[0].server if candidates else None
def select_gpu(
probes: Sequence[ServerProbe],
*,
server_name: str | None = None,
gpu_index: int | None = None,
force: bool = False,
include_not_ready: bool = False,
) -> GpuSelection | None:
"""run が使う GPU を選ぶ(SPEC_03「GPU の空き判定と選択」).
自動選択では,候補サーバ(`data_ready` のもの.`include_not_ready` なら全サーバ.
`server_name` を指定したらそのサーバのみ)全体を通して,まず「未使用」を priority 昇順
(同値は `probes` の順)→ GPU 番号の昇順で探し,無ければ同じ順で「空き(利用者あり)」を
探す(未使用がサーバの優先順位より優先される).接続できないサーバと GPU の状態の取得に
失敗したサーバは候補から外す.
Args:
probes: 各サーバの状況(設定の記載順で渡す.同じ priority の順位に使う).
server_name: `--server` の値.
gpu_index: `--gpu` の値(`server_name` が必須).
force: 指定した GPU が使用中・状態不明でも選ぶ(`gpu_index` 指定時のみ有効).
include_not_ready: `data_ready: false` のサーバも候補にする(`--no-data`).
Returns:
選んだ GPU.自動選択で未使用・空き(利用者あり)のどちらも無ければ None.
Raises:
ValueError: 指定の組み合わせが不正(`--gpu` だけ・`--force` だけ),指定したサーバが
設定に無い・データ未配置・接続不可,指定した GPU が無い・使用中・状態不明
(`force` 無し)の場合.
"""
check_selection_options(server_name=server_name, gpu_index=gpu_index, force=force)
if server_name is not None:
probe = _find_probe(probes, server_name)
if not probe.server.data_ready and not include_not_ready:
raise ValueError(
f"{probe.server.name} は臨床データが未配置(data_ready: false)である"
"(データを使わないジョブなら --no-data を付ける)"
)
if not probe.reachable:
raise ValueError(f"{probe.server.name} に接続できない({probe.error})")
if gpu_index is not None:
return _select_explicit(probe, gpu_index, force=force)
candidates = [probe]
else:
candidates = sorted(
(p for p in probes if p.reachable and (include_not_ready or p.server.data_ready)),
key=lambda p: p.server.priority,
)
for wanted in SELECTABLE_STATES:
for probe in candidates:
output = probe.output
if output is None or not output.gpu_query_ok:
continue
for gpu in sorted(
(g for g in output.gpus if g.index is not None), key=lambda g: g.index or 0
):
if gpu.state is wanted and gpu.index is not None:
return GpuSelection(probe.server, gpu.index, gpu.state, gpu)
return None
def display_user_name(user: str | None, own_user: str) -> str:
"""計算プロセスの所有ユーザーを表示用の名前にする.
Args:
user: 所有ユーザー.ps で取れなければ None.
own_user: 自分のユーザー名(ssh の接続ユーザー).
Returns:
自分なら「自分」,取れなければ「不明」,それ以外はユーザー名.
"""
if user is None:
return "不明"
if user == own_user:
return "自分"
return user
def gpu_users(gpu: GpuInfo, own_user: str) -> list[str]:
"""GPU の計算プロセスの所有ユーザーを表示用に並べる(重複は 1 つにまとめ,出現順).
Args:
gpu: GPU の状況.
own_user: 自分のユーザー名(ssh の接続ユーザー).一致するものは「自分」と表示する.
Returns:
ユーザー名の一覧(自分は「自分」,所有者を取れなかったものは「不明」).
"""
names: list[str] = []
for process in gpu.processes:
name = display_user_name(process.user, own_user)
if name not in names:
names.append(name)
return names
def servers_with_session(
probes: Sequence[ServerProbe], name: str, *, jobs_only: bool
) -> list[ServerProbe]:
"""指定した名前の tmux セッションがあるサーバを返す.
Args:
probes: 各サーバの状況(接続できなかったサーバは対象外).
name: セッション(ジョブ)名.
jobs_only: True ならこのツールのジョブ(ジョブログがあるもの)だけを見る.
Returns:
該当するサーバの状況(`probes` の順).
"""
found: list[ServerProbe] = []
for probe in probes:
if probe.output is None:
continue
names = probe.output.jobs if jobs_only else probe.output.sessions
if name in names:
found.append(probe)
return found