simjecture 0.1.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.
- conjecture_solver/__init__.py +10 -0
- conjecture_solver/__main__.py +4 -0
- conjecture_solver/action_handlers.py +459 -0
- conjecture_solver/adapters/__init__.py +7 -0
- conjecture_solver/adapters/base.py +96 -0
- conjecture_solver/adapters/fake.py +155 -0
- conjecture_solver/adapters/pic.py +196 -0
- conjecture_solver/adapters/warpx.py +1375 -0
- conjecture_solver/autonomous_research.py +1071 -0
- conjecture_solver/benchmarks/__init__.py +6 -0
- conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
- conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
- conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
- conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
- conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
- conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
- conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
- conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
- conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
- conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
- conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
- conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
- conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
- conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
- conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
- conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
- conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
- conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
- conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
- conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
- conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
- conjecture_solver/campaign.py +406 -0
- conjecture_solver/cli.py +963 -0
- conjecture_solver/confirmation.py +287 -0
- conjecture_solver/control.py +234 -0
- conjecture_solver/deployment.py +874 -0
- conjecture_solver/discovery.py +68 -0
- conjecture_solver/domains/__init__.py +40 -0
- conjecture_solver/domains/base.py +157 -0
- conjecture_solver/domains/kinetic_sufficiency.py +521 -0
- conjecture_solver/ledger.py +238 -0
- conjecture_solver/lifecycle.py +131 -0
- conjecture_solver/literature.py +494 -0
- conjecture_solver/llm.py +228 -0
- conjecture_solver/models.py +279 -0
- conjecture_solver/mvp_agent.py +3212 -0
- conjecture_solver/mvp_claims.py +1279 -0
- conjecture_solver/mvp_control.py +322 -0
- conjecture_solver/mvp_guidance.py +172 -0
- conjecture_solver/mvp_launch.py +1296 -0
- conjecture_solver/mvp_monitor.py +1487 -0
- conjecture_solver/mvp_skills.py +538 -0
- conjecture_solver/orchestration.py +578 -0
- conjecture_solver/outbox.py +526 -0
- conjecture_solver/parameters.py +394 -0
- conjecture_solver/proposals.py +427 -0
- conjecture_solver/research_tools.py +278 -0
- conjecture_solver/schema_export.py +246 -0
- conjecture_solver/search.py +1047 -0
- conjecture_solver/semantics.py +146 -0
- conjecture_solver/tui/__init__.py +7 -0
- conjecture_solver/tui/app.py +202 -0
- conjecture_solver/tui/claim_views.py +213 -0
- conjecture_solver/tui/screens.py +1014 -0
- conjecture_solver/warpx_analysis.py +184 -0
- conjecture_solver/warpx_campaign.py +148 -0
- conjecture_solver/warpx_confirmation.py +617 -0
- simjecture-0.1.0.dist-info/METADATA +400 -0
- simjecture-0.1.0.dist-info/RECORD +75 -0
- simjecture-0.1.0.dist-info/WHEEL +4 -0
- simjecture-0.1.0.dist-info/entry_points.txt +4 -0
- simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
- simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
|
@@ -0,0 +1,1296 @@
|
|
|
1
|
+
"""Safe subprocess construction for operator-launched MVP campaigns.
|
|
2
|
+
|
|
3
|
+
The launcher materializes operator inputs as files and never builds a shell
|
|
4
|
+
string. Hypothesis text stays out of argv except as a path to those files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fcntl
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from contextlib import suppress
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import IO, Any
|
|
23
|
+
|
|
24
|
+
from pydantic import Field, model_validator
|
|
25
|
+
|
|
26
|
+
from .models import StrictModel
|
|
27
|
+
from .mvp_control import ControlCommand, write_control
|
|
28
|
+
|
|
29
|
+
OPERATOR_INPUT_DIR = "operator_input"
|
|
30
|
+
CONTROLLER_LOG_NAME = "controller.log"
|
|
31
|
+
LAUNCH_RECORD_NAME = "launch.json"
|
|
32
|
+
SUPERVISOR_RECORD_NAME = "supervisor.json"
|
|
33
|
+
HYPOTHESIS_FILE_NAME = "hypothesis.txt"
|
|
34
|
+
INSTRUCTION_FILE_NAME = "instruction.txt"
|
|
35
|
+
GUIDED_COMMISSIONING_DIR_NAME = "guided_commissioning"
|
|
36
|
+
RUN_LOCK_NAME = "runner.lock"
|
|
37
|
+
CAMPAIGN_ID_PATTERN = r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MVPLaunchRequest(StrictModel):
|
|
41
|
+
hypothesis: str = Field(min_length=1)
|
|
42
|
+
instruction: str | None = None
|
|
43
|
+
campaign_id: str = Field(pattern=CAMPAIGN_ID_PATTERN)
|
|
44
|
+
output_directory: str
|
|
45
|
+
max_wall_seconds: float = Field(default=21_600.0, gt=0)
|
|
46
|
+
max_command_seconds: float = Field(default=600.0, gt=0)
|
|
47
|
+
max_workspace_mb: int = Field(default=512, ge=1)
|
|
48
|
+
max_file_mb: int = Field(default=64, ge=1)
|
|
49
|
+
max_memory_mb: int = Field(default=4096, ge=1)
|
|
50
|
+
max_iterations: int | None = Field(default=None, ge=1)
|
|
51
|
+
max_tool_output_chars: int = Field(default=30_000, ge=1)
|
|
52
|
+
command_heartbeat_seconds: float = Field(default=30.0, gt=0)
|
|
53
|
+
literature_search_timeout_seconds: float = Field(default=20.0, gt=0)
|
|
54
|
+
recent_full_turns: int = Field(default=12, ge=1)
|
|
55
|
+
max_model_retries: int = Field(default=3, ge=0)
|
|
56
|
+
model_failover_after: int = Field(default=2, ge=1)
|
|
57
|
+
ledger: str | None = None
|
|
58
|
+
guided_commission: str | None = None
|
|
59
|
+
skills_directory: str | None = None
|
|
60
|
+
capability_directory: str | None = None
|
|
61
|
+
use_glm: bool = False
|
|
62
|
+
reason: str | None = None
|
|
63
|
+
executable: tuple[str, ...] = ()
|
|
64
|
+
|
|
65
|
+
@model_validator(mode="after")
|
|
66
|
+
def validate_route(self) -> MVPLaunchRequest:
|
|
67
|
+
if self.use_glm and not (self.reason and self.reason.strip()):
|
|
68
|
+
raise ValueError("use_glm requires a non-empty reason")
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MVPLaunchPlan(StrictModel):
|
|
73
|
+
request: MVPLaunchRequest
|
|
74
|
+
output_directory: str
|
|
75
|
+
hypothesis_file: str
|
|
76
|
+
instruction_file: str | None = None
|
|
77
|
+
guided_commission_file: str | None = None
|
|
78
|
+
launch_record: str
|
|
79
|
+
controller_log: str
|
|
80
|
+
argv: tuple[str, ...]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ProcessIdentity(StrictModel):
|
|
84
|
+
pid: int = Field(ge=1)
|
|
85
|
+
starttime: str
|
|
86
|
+
argv: tuple[str, ...]
|
|
87
|
+
launched_at: str
|
|
88
|
+
run_directory: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LaunchConflictError(ValueError):
|
|
92
|
+
"""An output directory already contains a different launch contract."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RunAlreadyActiveError(RuntimeError):
|
|
96
|
+
"""A runner already owns the output-directory lock."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def default_mvp_executable() -> tuple[str, ...]:
|
|
100
|
+
return (sys.executable, "-m", "conjecture_solver")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_campaign_id(value: str) -> str:
|
|
104
|
+
import re
|
|
105
|
+
|
|
106
|
+
candidate = value.strip()
|
|
107
|
+
if not re.fullmatch(CAMPAIGN_ID_PATTERN, candidate):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"campaign id must start with a letter and use only letters, digits, "
|
|
110
|
+
"'_', '.', or '-'"
|
|
111
|
+
)
|
|
112
|
+
return candidate
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def materialize_operator_input(
|
|
116
|
+
request: MVPLaunchRequest,
|
|
117
|
+
*,
|
|
118
|
+
create_output: bool = True,
|
|
119
|
+
) -> MVPLaunchPlan:
|
|
120
|
+
output = Path(request.output_directory).expanduser().resolve()
|
|
121
|
+
if create_output:
|
|
122
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
if not output.is_dir():
|
|
124
|
+
raise NotADirectoryError(f"output path is not a directory: {output}")
|
|
125
|
+
if output_lock_is_held(output):
|
|
126
|
+
raise RunAlreadyActiveError(
|
|
127
|
+
f"another runner owns the output directory: {output}"
|
|
128
|
+
)
|
|
129
|
+
operator = _secure_operator_directory(output, create=True)
|
|
130
|
+
hypothesis_file = operator / HYPOTHESIS_FILE_NAME
|
|
131
|
+
hypothesis_text = request.hypothesis
|
|
132
|
+
instruction_file: Path | None = None
|
|
133
|
+
instruction_text: str | None = None
|
|
134
|
+
if request.instruction and request.instruction.strip():
|
|
135
|
+
instruction_file = operator / INSTRUCTION_FILE_NAME
|
|
136
|
+
instruction_text = request.instruction
|
|
137
|
+
|
|
138
|
+
guided_package = None
|
|
139
|
+
guided_file: Path | None = None
|
|
140
|
+
guided_sha256: str | None = None
|
|
141
|
+
if request.guided_commission:
|
|
142
|
+
from .mvp_guidance import MVPGuidedCommissioningPackage
|
|
143
|
+
|
|
144
|
+
guided_package = MVPGuidedCommissioningPackage.read(request.guided_commission)
|
|
145
|
+
guided_file = operator / GUIDED_COMMISSIONING_DIR_NAME / "manifest.json"
|
|
146
|
+
guided_sha256 = guided_package.package_sha256
|
|
147
|
+
|
|
148
|
+
normalized = request.model_copy(
|
|
149
|
+
update={
|
|
150
|
+
"output_directory": str(output),
|
|
151
|
+
"instruction": instruction_text,
|
|
152
|
+
"ledger": _normalized_optional_path(request.ledger),
|
|
153
|
+
"guided_commission": str(guided_file) if guided_file else None,
|
|
154
|
+
"skills_directory": _normalized_optional_path(request.skills_directory),
|
|
155
|
+
"capability_directory": _normalized_optional_path(
|
|
156
|
+
request.capability_directory
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
argv = build_mvp_argv(
|
|
161
|
+
normalized,
|
|
162
|
+
hypothesis_file=hypothesis_file,
|
|
163
|
+
instruction_file=instruction_file,
|
|
164
|
+
)
|
|
165
|
+
launch_record = operator / LAUNCH_RECORD_NAME
|
|
166
|
+
blocked = _automatic_resume_blockers(normalized, output)
|
|
167
|
+
record: dict[str, Any] = {
|
|
168
|
+
"schema_version": "0.2.0",
|
|
169
|
+
"campaign_id": normalized.campaign_id,
|
|
170
|
+
"hypothesis_file": hypothesis_file.name,
|
|
171
|
+
"hypothesis_sha256": _sha256_text(hypothesis_text),
|
|
172
|
+
"instruction_file": instruction_file.name if instruction_file else None,
|
|
173
|
+
"instruction_sha256": (
|
|
174
|
+
_sha256_text(instruction_text) if instruction_text is not None else None
|
|
175
|
+
),
|
|
176
|
+
"guided_commission": (
|
|
177
|
+
f"{GUIDED_COMMISSIONING_DIR_NAME}/manifest.json" if guided_file else None
|
|
178
|
+
),
|
|
179
|
+
"guided_commission_sha256": guided_sha256,
|
|
180
|
+
"output_directory": str(output),
|
|
181
|
+
"max_wall_seconds": normalized.max_wall_seconds,
|
|
182
|
+
"max_command_seconds": normalized.max_command_seconds,
|
|
183
|
+
"max_workspace_mb": normalized.max_workspace_mb,
|
|
184
|
+
"max_file_mb": normalized.max_file_mb,
|
|
185
|
+
"max_memory_mb": normalized.max_memory_mb,
|
|
186
|
+
"max_iterations": normalized.max_iterations,
|
|
187
|
+
"max_tool_output_chars": normalized.max_tool_output_chars,
|
|
188
|
+
"command_heartbeat_seconds": normalized.command_heartbeat_seconds,
|
|
189
|
+
"literature_search_timeout_seconds": (
|
|
190
|
+
normalized.literature_search_timeout_seconds
|
|
191
|
+
),
|
|
192
|
+
"recent_full_turns": normalized.recent_full_turns,
|
|
193
|
+
"max_model_retries": normalized.max_model_retries,
|
|
194
|
+
"model_failover_after": normalized.model_failover_after,
|
|
195
|
+
"ledger": _recorded_path(normalized.ledger, output),
|
|
196
|
+
"skills_directory": _recorded_path(normalized.skills_directory, output),
|
|
197
|
+
"capability_directory": _recorded_path(
|
|
198
|
+
normalized.capability_directory, output
|
|
199
|
+
),
|
|
200
|
+
"use_glm": normalized.use_glm,
|
|
201
|
+
"reason": normalized.reason,
|
|
202
|
+
"automatic_resume_blockers": blocked,
|
|
203
|
+
"argv": list(argv),
|
|
204
|
+
"written_at": datetime.now(UTC).isoformat(),
|
|
205
|
+
}
|
|
206
|
+
existing = _load_secure_json(launch_record, root=output)
|
|
207
|
+
if existing is not None:
|
|
208
|
+
_assert_launch_record_matches(
|
|
209
|
+
existing,
|
|
210
|
+
desired=record,
|
|
211
|
+
output=output,
|
|
212
|
+
hypothesis_text=hypothesis_text,
|
|
213
|
+
instruction_text=instruction_text,
|
|
214
|
+
)
|
|
215
|
+
if existing.get("schema_version") == "0.2.0":
|
|
216
|
+
return MVPLaunchPlan(
|
|
217
|
+
request=normalized,
|
|
218
|
+
output_directory=str(output),
|
|
219
|
+
hypothesis_file=str(hypothesis_file),
|
|
220
|
+
instruction_file=str(instruction_file) if instruction_file else None,
|
|
221
|
+
guided_commission_file=str(guided_file) if guided_file else None,
|
|
222
|
+
launch_record=str(launch_record),
|
|
223
|
+
controller_log=str(output / CONTROLLER_LOG_NAME),
|
|
224
|
+
argv=argv,
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
_assert_unrecorded_output_is_compatible(
|
|
228
|
+
output,
|
|
229
|
+
operator=operator,
|
|
230
|
+
request=normalized,
|
|
231
|
+
hypothesis_text=hypothesis_text,
|
|
232
|
+
instruction_text=instruction_text,
|
|
233
|
+
guided_commission_sha256=guided_sha256,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
_atomic_write_text(hypothesis_file, hypothesis_text)
|
|
237
|
+
if instruction_file is not None and instruction_text is not None:
|
|
238
|
+
_atomic_write_text(instruction_file, instruction_text)
|
|
239
|
+
if guided_package is not None and guided_file is not None:
|
|
240
|
+
_materialize_guided_package(guided_package, guided_file)
|
|
241
|
+
_atomic_write_text(launch_record, json.dumps(record, indent=2, sort_keys=True) + "\n")
|
|
242
|
+
return MVPLaunchPlan(
|
|
243
|
+
request=normalized,
|
|
244
|
+
output_directory=str(output),
|
|
245
|
+
hypothesis_file=str(hypothesis_file),
|
|
246
|
+
instruction_file=str(instruction_file) if instruction_file else None,
|
|
247
|
+
guided_commission_file=str(guided_file) if guided_file else None,
|
|
248
|
+
launch_record=str(launch_record),
|
|
249
|
+
controller_log=str(output / CONTROLLER_LOG_NAME),
|
|
250
|
+
argv=argv,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def build_mvp_argv(
|
|
255
|
+
request: MVPLaunchRequest,
|
|
256
|
+
*,
|
|
257
|
+
hypothesis_file: Path,
|
|
258
|
+
instruction_file: Path | None,
|
|
259
|
+
) -> tuple[str, ...]:
|
|
260
|
+
executable = request.executable or default_mvp_executable()
|
|
261
|
+
argv = [
|
|
262
|
+
*executable,
|
|
263
|
+
"mvp",
|
|
264
|
+
"--hypothesis-file",
|
|
265
|
+
str(hypothesis_file),
|
|
266
|
+
"--campaign-id",
|
|
267
|
+
request.campaign_id,
|
|
268
|
+
"--output",
|
|
269
|
+
str(Path(request.output_directory).expanduser().resolve()),
|
|
270
|
+
"--max-wall-seconds",
|
|
271
|
+
_format_number(request.max_wall_seconds),
|
|
272
|
+
"--max-command-seconds",
|
|
273
|
+
_format_number(request.max_command_seconds),
|
|
274
|
+
"--max-workspace-mb",
|
|
275
|
+
str(request.max_workspace_mb),
|
|
276
|
+
"--max-file-mb",
|
|
277
|
+
str(request.max_file_mb),
|
|
278
|
+
"--max-memory-mb",
|
|
279
|
+
str(request.max_memory_mb),
|
|
280
|
+
"--max-tool-output-chars",
|
|
281
|
+
str(request.max_tool_output_chars),
|
|
282
|
+
"--command-heartbeat-seconds",
|
|
283
|
+
_format_number(request.command_heartbeat_seconds),
|
|
284
|
+
"--literature-search-timeout-seconds",
|
|
285
|
+
_format_number(request.literature_search_timeout_seconds),
|
|
286
|
+
"--recent-full-turns",
|
|
287
|
+
str(request.recent_full_turns),
|
|
288
|
+
"--max-model-retries",
|
|
289
|
+
str(request.max_model_retries),
|
|
290
|
+
"--model-failover-after",
|
|
291
|
+
str(request.model_failover_after),
|
|
292
|
+
]
|
|
293
|
+
if instruction_file is not None:
|
|
294
|
+
argv.extend(["--instruction-file", str(instruction_file)])
|
|
295
|
+
if request.max_iterations is not None:
|
|
296
|
+
argv.extend(["--max-iterations", str(request.max_iterations)])
|
|
297
|
+
if request.ledger:
|
|
298
|
+
argv.extend(["--ledger", request.ledger])
|
|
299
|
+
if request.guided_commission:
|
|
300
|
+
argv.extend(["--guided-commission", request.guided_commission])
|
|
301
|
+
if request.skills_directory:
|
|
302
|
+
argv.extend(["--skills-directory", request.skills_directory])
|
|
303
|
+
if request.capability_directory:
|
|
304
|
+
argv.extend(["--capability-directory", request.capability_directory])
|
|
305
|
+
if request.use_glm:
|
|
306
|
+
argv.append("--use-glm")
|
|
307
|
+
if request.reason:
|
|
308
|
+
argv.extend(["--reason", request.reason])
|
|
309
|
+
if any("\x00" in item for item in argv):
|
|
310
|
+
raise ValueError("command arguments cannot contain NUL bytes")
|
|
311
|
+
return tuple(argv)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def read_process_identity(
|
|
315
|
+
pid: int,
|
|
316
|
+
argv: Sequence[str] = (),
|
|
317
|
+
*,
|
|
318
|
+
run_directory: str | Path | None = None,
|
|
319
|
+
) -> ProcessIdentity | None:
|
|
320
|
+
stat_path = Path(f"/proc/{pid}/stat")
|
|
321
|
+
if not stat_path.is_file():
|
|
322
|
+
return None
|
|
323
|
+
try:
|
|
324
|
+
stat_text = stat_path.read_text()
|
|
325
|
+
except OSError:
|
|
326
|
+
return None
|
|
327
|
+
starttime = _starttime_from_stat(stat_text)
|
|
328
|
+
if starttime is None:
|
|
329
|
+
return None
|
|
330
|
+
actual_argv = _read_process_argv(pid)
|
|
331
|
+
if actual_argv is None and argv:
|
|
332
|
+
actual_argv = tuple(argv)
|
|
333
|
+
if actual_argv is None:
|
|
334
|
+
return None
|
|
335
|
+
return ProcessIdentity(
|
|
336
|
+
pid=pid,
|
|
337
|
+
starttime=starttime,
|
|
338
|
+
argv=actual_argv,
|
|
339
|
+
launched_at=datetime.now(UTC).isoformat(),
|
|
340
|
+
run_directory=(
|
|
341
|
+
str(Path(run_directory).expanduser().resolve())
|
|
342
|
+
if run_directory is not None
|
|
343
|
+
else None
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def process_identity_matches(expected: ProcessIdentity) -> bool:
|
|
349
|
+
current = read_process_identity(expected.pid)
|
|
350
|
+
if current is None:
|
|
351
|
+
return False
|
|
352
|
+
if current.starttime != expected.starttime or current.argv != expected.argv:
|
|
353
|
+
return False
|
|
354
|
+
state = _state_from_stat_path(expected.pid)
|
|
355
|
+
return state not in {None, "Z", "X"}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def write_supervisor_record(output_directory: str | Path, identity: ProcessIdentity) -> Path:
|
|
359
|
+
root = Path(output_directory).expanduser().resolve()
|
|
360
|
+
operator = _secure_operator_directory(root, create=True)
|
|
361
|
+
path = operator / SUPERVISOR_RECORD_NAME
|
|
362
|
+
bound = identity.model_copy(update={"run_directory": str(root)})
|
|
363
|
+
payload = {
|
|
364
|
+
"schema_version": "0.2.0",
|
|
365
|
+
"pid": bound.pid,
|
|
366
|
+
"starttime": bound.starttime,
|
|
367
|
+
"argv": list(bound.argv),
|
|
368
|
+
"launched_at": bound.launched_at,
|
|
369
|
+
"run_directory": bound.run_directory,
|
|
370
|
+
}
|
|
371
|
+
_atomic_write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
372
|
+
return path
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def load_supervisor_record(output_directory: str | Path) -> ProcessIdentity | None:
|
|
376
|
+
root = Path(output_directory).expanduser().resolve()
|
|
377
|
+
try:
|
|
378
|
+
operator = _secure_operator_directory(root, create=False)
|
|
379
|
+
except (FileNotFoundError, ValueError):
|
|
380
|
+
return None
|
|
381
|
+
path = operator / SUPERVISOR_RECORD_NAME
|
|
382
|
+
payload = _load_secure_json(path, root=root)
|
|
383
|
+
if payload is None or payload.get("schema_version") != "0.2.0":
|
|
384
|
+
return None
|
|
385
|
+
pid = payload.get("pid")
|
|
386
|
+
starttime = payload.get("starttime")
|
|
387
|
+
argv = payload.get("argv")
|
|
388
|
+
launched_at = payload.get("launched_at")
|
|
389
|
+
run_directory = payload.get("run_directory")
|
|
390
|
+
if not isinstance(pid, int) or pid < 1:
|
|
391
|
+
return None
|
|
392
|
+
if not isinstance(starttime, str) or not isinstance(launched_at, str):
|
|
393
|
+
return None
|
|
394
|
+
if not isinstance(argv, list) or not all(isinstance(item, str) for item in argv):
|
|
395
|
+
return None
|
|
396
|
+
if not isinstance(run_directory, str):
|
|
397
|
+
return None
|
|
398
|
+
try:
|
|
399
|
+
recorded_root = Path(run_directory).expanduser().resolve()
|
|
400
|
+
except OSError:
|
|
401
|
+
return None
|
|
402
|
+
if recorded_root != root or not _argv_targets_run(tuple(argv), root):
|
|
403
|
+
return None
|
|
404
|
+
identity = ProcessIdentity(
|
|
405
|
+
pid=pid,
|
|
406
|
+
starttime=starttime,
|
|
407
|
+
argv=tuple(argv),
|
|
408
|
+
launched_at=launched_at,
|
|
409
|
+
run_directory=str(root),
|
|
410
|
+
)
|
|
411
|
+
return identity if process_identity_matches(identity) else None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class ManagedCampaign:
|
|
415
|
+
"""A runner subprocess started by this operator interface."""
|
|
416
|
+
|
|
417
|
+
def __init__(
|
|
418
|
+
self,
|
|
419
|
+
plan: MVPLaunchPlan,
|
|
420
|
+
process: subprocess.Popen[bytes],
|
|
421
|
+
identity: ProcessIdentity,
|
|
422
|
+
log_handle: IO[bytes],
|
|
423
|
+
) -> None:
|
|
424
|
+
self.plan = plan
|
|
425
|
+
self.process = process
|
|
426
|
+
self.identity = identity
|
|
427
|
+
self.log_handle = log_handle
|
|
428
|
+
|
|
429
|
+
def poll(self) -> int | None:
|
|
430
|
+
return self.process.poll()
|
|
431
|
+
|
|
432
|
+
def is_alive(self) -> bool:
|
|
433
|
+
if self.process.poll() is not None:
|
|
434
|
+
return False
|
|
435
|
+
return process_identity_matches(self.identity)
|
|
436
|
+
|
|
437
|
+
def cancel(
|
|
438
|
+
self,
|
|
439
|
+
*,
|
|
440
|
+
wait_interrupt_seconds: float = 20.0,
|
|
441
|
+
wait_terminate_seconds: float = 10.0,
|
|
442
|
+
sleep: Any = time.sleep,
|
|
443
|
+
) -> str:
|
|
444
|
+
result = request_graceful_cancel(
|
|
445
|
+
self.identity,
|
|
446
|
+
wait_interrupt_seconds=wait_interrupt_seconds,
|
|
447
|
+
wait_terminate_seconds=wait_terminate_seconds,
|
|
448
|
+
sleep=sleep,
|
|
449
|
+
)
|
|
450
|
+
with suppress(Exception):
|
|
451
|
+
self.process.poll()
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
def close(self) -> None:
|
|
455
|
+
with suppress(OSError):
|
|
456
|
+
self.log_handle.close()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def start_managed_campaign(
|
|
460
|
+
plan: MVPLaunchPlan,
|
|
461
|
+
*,
|
|
462
|
+
popen: Any = subprocess.Popen,
|
|
463
|
+
) -> ManagedCampaign:
|
|
464
|
+
log_path = Path(plan.controller_log)
|
|
465
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
466
|
+
log_handle = log_path.open("ab", buffering=0)
|
|
467
|
+
try:
|
|
468
|
+
process = popen(
|
|
469
|
+
list(plan.argv),
|
|
470
|
+
stdout=log_handle,
|
|
471
|
+
stderr=subprocess.STDOUT,
|
|
472
|
+
start_new_session=True,
|
|
473
|
+
)
|
|
474
|
+
except Exception:
|
|
475
|
+
log_handle.close()
|
|
476
|
+
raise
|
|
477
|
+
identity = read_process_identity(
|
|
478
|
+
process.pid,
|
|
479
|
+
plan.argv,
|
|
480
|
+
run_directory=plan.output_directory,
|
|
481
|
+
)
|
|
482
|
+
if identity is None:
|
|
483
|
+
identity = ProcessIdentity(
|
|
484
|
+
pid=process.pid,
|
|
485
|
+
starttime="unavailable",
|
|
486
|
+
argv=plan.argv,
|
|
487
|
+
launched_at=datetime.now(UTC).isoformat(),
|
|
488
|
+
run_directory=plan.output_directory,
|
|
489
|
+
)
|
|
490
|
+
write_supervisor_record(plan.output_directory, identity)
|
|
491
|
+
return ManagedCampaign(
|
|
492
|
+
plan=plan,
|
|
493
|
+
process=process,
|
|
494
|
+
identity=identity,
|
|
495
|
+
log_handle=log_handle,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class ResumeError(ValueError):
|
|
500
|
+
"""A campaign cannot be resumed from the stored operator launch record."""
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def persist_operator_launch(
|
|
504
|
+
*,
|
|
505
|
+
hypothesis: str,
|
|
506
|
+
instruction: str | None,
|
|
507
|
+
campaign_id: str,
|
|
508
|
+
output_directory: str | Path,
|
|
509
|
+
max_wall_seconds: float,
|
|
510
|
+
max_command_seconds: float,
|
|
511
|
+
max_workspace_mb: int,
|
|
512
|
+
max_file_mb: int,
|
|
513
|
+
max_memory_mb: int,
|
|
514
|
+
max_iterations: int | None = None,
|
|
515
|
+
max_tool_output_chars: int = 30_000,
|
|
516
|
+
command_heartbeat_seconds: float = 30.0,
|
|
517
|
+
literature_search_timeout_seconds: float = 20.0,
|
|
518
|
+
recent_full_turns: int = 12,
|
|
519
|
+
max_model_retries: int = 3,
|
|
520
|
+
model_failover_after: int = 2,
|
|
521
|
+
ledger: str | None = None,
|
|
522
|
+
guided_commission: str | None = None,
|
|
523
|
+
skills_directory: str | None = None,
|
|
524
|
+
capability_directory: str | None = None,
|
|
525
|
+
use_glm: bool = False,
|
|
526
|
+
reason: str | None = None,
|
|
527
|
+
) -> MVPLaunchPlan:
|
|
528
|
+
"""Write the complete structured launch contract for safe later resume."""
|
|
529
|
+
|
|
530
|
+
return materialize_operator_input(
|
|
531
|
+
MVPLaunchRequest(
|
|
532
|
+
hypothesis=hypothesis,
|
|
533
|
+
instruction=instruction,
|
|
534
|
+
campaign_id=validate_campaign_id(campaign_id),
|
|
535
|
+
output_directory=str(output_directory),
|
|
536
|
+
max_wall_seconds=max_wall_seconds,
|
|
537
|
+
max_command_seconds=max_command_seconds,
|
|
538
|
+
max_workspace_mb=max_workspace_mb,
|
|
539
|
+
max_file_mb=max_file_mb,
|
|
540
|
+
max_memory_mb=max_memory_mb,
|
|
541
|
+
max_iterations=max_iterations,
|
|
542
|
+
max_tool_output_chars=max_tool_output_chars,
|
|
543
|
+
command_heartbeat_seconds=command_heartbeat_seconds,
|
|
544
|
+
literature_search_timeout_seconds=literature_search_timeout_seconds,
|
|
545
|
+
recent_full_turns=recent_full_turns,
|
|
546
|
+
max_model_retries=max_model_retries,
|
|
547
|
+
model_failover_after=model_failover_after,
|
|
548
|
+
ledger=ledger,
|
|
549
|
+
guided_commission=guided_commission,
|
|
550
|
+
skills_directory=skills_directory,
|
|
551
|
+
capability_directory=capability_directory,
|
|
552
|
+
use_glm=use_glm,
|
|
553
|
+
reason=reason,
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def load_launch_request(run_directory: str | Path) -> MVPLaunchRequest | None:
|
|
559
|
+
root = Path(run_directory).expanduser().resolve()
|
|
560
|
+
try:
|
|
561
|
+
operator = _secure_operator_directory(root, create=False)
|
|
562
|
+
except (FileNotFoundError, ValueError):
|
|
563
|
+
return None
|
|
564
|
+
path = operator / LAUNCH_RECORD_NAME
|
|
565
|
+
payload = _load_secure_json(path, root=root)
|
|
566
|
+
if payload is None:
|
|
567
|
+
return None
|
|
568
|
+
schema_version = payload.get("schema_version")
|
|
569
|
+
if schema_version == "0.1.0":
|
|
570
|
+
raise ResumeError(
|
|
571
|
+
"legacy launch record does not contain the complete MVP contract; "
|
|
572
|
+
"repeat the reviewed original command"
|
|
573
|
+
)
|
|
574
|
+
if schema_version != "0.2.0":
|
|
575
|
+
raise ResumeError(f"unsupported launch record schema: {schema_version!r}")
|
|
576
|
+
blockers = payload.get("automatic_resume_blockers") or []
|
|
577
|
+
if not isinstance(blockers, list) or not all(
|
|
578
|
+
isinstance(item, str) for item in blockers
|
|
579
|
+
):
|
|
580
|
+
raise ResumeError("launch record has invalid automatic_resume_blockers")
|
|
581
|
+
if blockers:
|
|
582
|
+
raise ResumeError(
|
|
583
|
+
"automatic resume is disabled for this non-self-contained contract: "
|
|
584
|
+
+ "; ".join(blockers)
|
|
585
|
+
+ ". Repeat the reviewed original command instead."
|
|
586
|
+
)
|
|
587
|
+
hypothesis_name = payload.get("hypothesis_file") or HYPOTHESIS_FILE_NAME
|
|
588
|
+
if hypothesis_name != HYPOTHESIS_FILE_NAME:
|
|
589
|
+
raise ResumeError("launch record hypothesis_file must be hypothesis.txt")
|
|
590
|
+
hypothesis_path = _contained_regular_file(
|
|
591
|
+
root,
|
|
592
|
+
operator / HYPOTHESIS_FILE_NAME,
|
|
593
|
+
description="stored hypothesis",
|
|
594
|
+
)
|
|
595
|
+
try:
|
|
596
|
+
hypothesis = hypothesis_path.read_text()
|
|
597
|
+
except OSError:
|
|
598
|
+
raise ResumeError("stored hypothesis could not be read") from None
|
|
599
|
+
expected_hypothesis_hash = payload.get("hypothesis_sha256")
|
|
600
|
+
if expected_hypothesis_hash != _sha256_text(hypothesis):
|
|
601
|
+
raise ResumeError("stored hypothesis identity does not match launch.json")
|
|
602
|
+
instruction = None
|
|
603
|
+
instruction_name = payload.get("instruction_file")
|
|
604
|
+
if instruction_name is not None:
|
|
605
|
+
if instruction_name != INSTRUCTION_FILE_NAME:
|
|
606
|
+
raise ResumeError("launch record instruction_file must be instruction.txt")
|
|
607
|
+
instruction_path = _contained_regular_file(
|
|
608
|
+
root,
|
|
609
|
+
operator / INSTRUCTION_FILE_NAME,
|
|
610
|
+
description="stored instruction",
|
|
611
|
+
)
|
|
612
|
+
instruction = instruction_path.read_text()
|
|
613
|
+
if payload.get("instruction_sha256") != _sha256_text(instruction):
|
|
614
|
+
raise ResumeError("stored instruction identity does not match launch.json")
|
|
615
|
+
|
|
616
|
+
guided_commission = None
|
|
617
|
+
guided_name = payload.get("guided_commission")
|
|
618
|
+
if guided_name is not None:
|
|
619
|
+
expected = f"{GUIDED_COMMISSIONING_DIR_NAME}/manifest.json"
|
|
620
|
+
if guided_name != expected:
|
|
621
|
+
raise ResumeError("guided commissioning path is not self-contained")
|
|
622
|
+
guided_path = _contained_regular_file(
|
|
623
|
+
root,
|
|
624
|
+
operator / GUIDED_COMMISSIONING_DIR_NAME / "manifest.json",
|
|
625
|
+
description="guided commissioning manifest",
|
|
626
|
+
)
|
|
627
|
+
from .mvp_guidance import MVPGuidedCommissioningPackage
|
|
628
|
+
|
|
629
|
+
package = MVPGuidedCommissioningPackage.read(guided_path)
|
|
630
|
+
if payload.get("guided_commission_sha256") != package.package_sha256:
|
|
631
|
+
raise ResumeError("guided commissioning identity does not match launch.json")
|
|
632
|
+
guided_commission = str(guided_path)
|
|
633
|
+
|
|
634
|
+
try:
|
|
635
|
+
request = MVPLaunchRequest(
|
|
636
|
+
hypothesis=hypothesis,
|
|
637
|
+
instruction=instruction,
|
|
638
|
+
campaign_id=str(payload.get("campaign_id") or root.name),
|
|
639
|
+
output_directory=str(root),
|
|
640
|
+
max_wall_seconds=float(payload.get("max_wall_seconds", 21_600.0)),
|
|
641
|
+
max_command_seconds=float(payload.get("max_command_seconds", 600.0)),
|
|
642
|
+
max_workspace_mb=int(payload.get("max_workspace_mb", 512)),
|
|
643
|
+
max_file_mb=int(payload.get("max_file_mb", 64)),
|
|
644
|
+
max_memory_mb=int(payload.get("max_memory_mb", 4096)),
|
|
645
|
+
max_iterations=(
|
|
646
|
+
int(payload["max_iterations"])
|
|
647
|
+
if payload.get("max_iterations") is not None
|
|
648
|
+
else None
|
|
649
|
+
),
|
|
650
|
+
max_tool_output_chars=int(payload.get("max_tool_output_chars", 30_000)),
|
|
651
|
+
command_heartbeat_seconds=float(
|
|
652
|
+
payload.get("command_heartbeat_seconds", 30.0)
|
|
653
|
+
),
|
|
654
|
+
literature_search_timeout_seconds=float(
|
|
655
|
+
payload.get("literature_search_timeout_seconds", 20.0)
|
|
656
|
+
),
|
|
657
|
+
recent_full_turns=int(payload.get("recent_full_turns", 12)),
|
|
658
|
+
max_model_retries=int(payload.get("max_model_retries", 3)),
|
|
659
|
+
model_failover_after=int(payload.get("model_failover_after", 2)),
|
|
660
|
+
ledger=_load_recorded_path(payload.get("ledger"), root, "ledger"),
|
|
661
|
+
guided_commission=guided_commission,
|
|
662
|
+
skills_directory=_load_recorded_path(
|
|
663
|
+
payload.get("skills_directory"), root, "skills directory"
|
|
664
|
+
),
|
|
665
|
+
capability_directory=_load_recorded_path(
|
|
666
|
+
payload.get("capability_directory"), root, "capability directory"
|
|
667
|
+
),
|
|
668
|
+
use_glm=payload.get("use_glm") is True,
|
|
669
|
+
reason=(
|
|
670
|
+
str(payload["reason"])
|
|
671
|
+
if isinstance(payload.get("reason"), str)
|
|
672
|
+
else None
|
|
673
|
+
),
|
|
674
|
+
)
|
|
675
|
+
except ResumeError:
|
|
676
|
+
raise
|
|
677
|
+
except Exception as error:
|
|
678
|
+
raise ResumeError(f"stored launch contract is invalid: {error}") from error
|
|
679
|
+
return request
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def request_verified_pause(
|
|
683
|
+
run_directory: str | Path,
|
|
684
|
+
*,
|
|
685
|
+
source: str = "operator",
|
|
686
|
+
) -> str:
|
|
687
|
+
"""Ask a verified live runner to pause after the current action."""
|
|
688
|
+
|
|
689
|
+
record = load_supervisor_record(run_directory)
|
|
690
|
+
if record is None or not process_identity_matches(record):
|
|
691
|
+
return "no verified running process; nothing to pause"
|
|
692
|
+
write_control(run_directory, ControlCommand.PAUSE, source=source)
|
|
693
|
+
return "pause requested at the next action boundary"
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def prepare_resume(run_directory: str | Path) -> MVPLaunchPlan:
|
|
697
|
+
root = Path(run_directory).expanduser().resolve()
|
|
698
|
+
if not root.is_dir():
|
|
699
|
+
raise ResumeError(f"run directory does not exist: {root}")
|
|
700
|
+
if (root / "mvp_report.json").is_file():
|
|
701
|
+
raise ResumeError(
|
|
702
|
+
"a terminal report exists; repeating the original mvp command replays it"
|
|
703
|
+
)
|
|
704
|
+
if output_lock_is_held(root):
|
|
705
|
+
raise ResumeError("a runner already owns this output directory; attach instead")
|
|
706
|
+
record = load_supervisor_record(root)
|
|
707
|
+
if record is not None and process_identity_matches(record):
|
|
708
|
+
raise ResumeError(f"process still running (pid={record.pid}); attach instead")
|
|
709
|
+
request = load_launch_request(root)
|
|
710
|
+
if request is None:
|
|
711
|
+
raise ResumeError(
|
|
712
|
+
"no operator_input/launch.json; repeat the original mvp command to resume"
|
|
713
|
+
)
|
|
714
|
+
return materialize_operator_input(request)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def request_graceful_cancel(
|
|
718
|
+
identity: ProcessIdentity,
|
|
719
|
+
*,
|
|
720
|
+
wait_interrupt_seconds: float = 20.0,
|
|
721
|
+
wait_terminate_seconds: float = 10.0,
|
|
722
|
+
sleep: Any = time.sleep,
|
|
723
|
+
) -> str:
|
|
724
|
+
"""Send SIGINT to a verified child, then SIGTERM if it does not exit.
|
|
725
|
+
|
|
726
|
+
Never signals an unverified PID. Does not use SIGKILL or SIGSTOP.
|
|
727
|
+
"""
|
|
728
|
+
|
|
729
|
+
if not process_identity_matches(identity):
|
|
730
|
+
return "process identity does not match; no signal sent"
|
|
731
|
+
if not _signal_verified(identity, signal.SIGINT):
|
|
732
|
+
return "process already exited"
|
|
733
|
+
if _wait_for_exit(identity, wait_interrupt_seconds, sleep=sleep):
|
|
734
|
+
return "interrupted"
|
|
735
|
+
if not process_identity_matches(identity):
|
|
736
|
+
return "process identity changed after interrupt; no further signal sent"
|
|
737
|
+
if not _signal_verified(identity, signal.SIGTERM):
|
|
738
|
+
return "interrupted"
|
|
739
|
+
if _wait_for_exit(identity, wait_terminate_seconds, sleep=sleep):
|
|
740
|
+
return "terminated"
|
|
741
|
+
return "cancel requested; process still running"
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _signal_verified(identity: ProcessIdentity, sig: signal.Signals) -> bool:
|
|
745
|
+
if not process_identity_matches(identity):
|
|
746
|
+
return False
|
|
747
|
+
try:
|
|
748
|
+
group = os.getpgid(identity.pid)
|
|
749
|
+
if group == identity.pid:
|
|
750
|
+
os.killpg(group, sig)
|
|
751
|
+
else:
|
|
752
|
+
os.kill(identity.pid, sig)
|
|
753
|
+
except ProcessLookupError:
|
|
754
|
+
return False
|
|
755
|
+
return True
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _wait_for_exit(
|
|
759
|
+
identity: ProcessIdentity,
|
|
760
|
+
timeout_seconds: float,
|
|
761
|
+
*,
|
|
762
|
+
sleep: Any,
|
|
763
|
+
) -> bool:
|
|
764
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
765
|
+
while time.monotonic() < deadline:
|
|
766
|
+
if not process_identity_matches(identity):
|
|
767
|
+
return True
|
|
768
|
+
sleep(0.05)
|
|
769
|
+
return not process_identity_matches(identity)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class MVPOutputLock:
|
|
773
|
+
"""Non-blocking exclusive ownership of one durable run directory."""
|
|
774
|
+
|
|
775
|
+
def __init__(self, run_directory: str | Path) -> None:
|
|
776
|
+
self.root = Path(run_directory).expanduser().resolve()
|
|
777
|
+
self._fd: int | None = None
|
|
778
|
+
|
|
779
|
+
def __enter__(self) -> MVPOutputLock:
|
|
780
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
781
|
+
operator = _secure_operator_directory(self.root, create=True)
|
|
782
|
+
path = operator / RUN_LOCK_NAME
|
|
783
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC
|
|
784
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
785
|
+
flags |= os.O_NOFOLLOW
|
|
786
|
+
try:
|
|
787
|
+
fd = os.open(path, flags, 0o600)
|
|
788
|
+
except OSError as error:
|
|
789
|
+
raise RunAlreadyActiveError(
|
|
790
|
+
f"cannot safely open the run lock: {path}: {error}"
|
|
791
|
+
) from error
|
|
792
|
+
try:
|
|
793
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
794
|
+
except BlockingIOError as error:
|
|
795
|
+
os.close(fd)
|
|
796
|
+
raise RunAlreadyActiveError(
|
|
797
|
+
f"another runner owns the output directory: {self.root}"
|
|
798
|
+
) from error
|
|
799
|
+
self._fd = fd
|
|
800
|
+
os.ftruncate(fd, 0)
|
|
801
|
+
os.write(fd, f"pid={os.getpid()}\n".encode())
|
|
802
|
+
os.fsync(fd)
|
|
803
|
+
return self
|
|
804
|
+
|
|
805
|
+
def __exit__(self, *_args: object) -> None:
|
|
806
|
+
if self._fd is None:
|
|
807
|
+
return
|
|
808
|
+
with suppress(OSError):
|
|
809
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
810
|
+
with suppress(OSError):
|
|
811
|
+
os.close(self._fd)
|
|
812
|
+
self._fd = None
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def output_lock_is_held(run_directory: str | Path) -> bool:
|
|
816
|
+
"""Return true when another process owns the durable runner lock."""
|
|
817
|
+
|
|
818
|
+
root = Path(run_directory).expanduser().resolve()
|
|
819
|
+
try:
|
|
820
|
+
operator = _secure_operator_directory(root, create=False)
|
|
821
|
+
except FileNotFoundError:
|
|
822
|
+
return False
|
|
823
|
+
except ValueError:
|
|
824
|
+
return True
|
|
825
|
+
path = operator / RUN_LOCK_NAME
|
|
826
|
+
if path.is_symlink():
|
|
827
|
+
return True
|
|
828
|
+
if not path.exists():
|
|
829
|
+
return False
|
|
830
|
+
flags = os.O_RDWR | os.O_CLOEXEC
|
|
831
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
832
|
+
flags |= os.O_NOFOLLOW
|
|
833
|
+
try:
|
|
834
|
+
fd = os.open(path, flags)
|
|
835
|
+
except OSError:
|
|
836
|
+
return True
|
|
837
|
+
try:
|
|
838
|
+
try:
|
|
839
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
840
|
+
except BlockingIOError:
|
|
841
|
+
return True
|
|
842
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
843
|
+
return False
|
|
844
|
+
finally:
|
|
845
|
+
os.close(fd)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _secure_operator_directory(root: Path, *, create: bool) -> Path:
|
|
849
|
+
if not root.is_dir():
|
|
850
|
+
raise FileNotFoundError(f"run directory does not exist: {root}")
|
|
851
|
+
operator = root / OPERATOR_INPUT_DIR
|
|
852
|
+
if operator.is_symlink():
|
|
853
|
+
raise ValueError("operator_input must not be a symlink")
|
|
854
|
+
if not operator.exists():
|
|
855
|
+
if not create:
|
|
856
|
+
raise FileNotFoundError(operator)
|
|
857
|
+
operator.mkdir(mode=0o700)
|
|
858
|
+
if not operator.is_dir() or operator.resolve() != operator:
|
|
859
|
+
raise ValueError("operator_input must be a contained regular directory")
|
|
860
|
+
return operator
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _contained_regular_file(root: Path, path: Path, *, description: str) -> Path:
|
|
864
|
+
if path.is_symlink():
|
|
865
|
+
raise ResumeError(f"{description} must not be a symlink")
|
|
866
|
+
try:
|
|
867
|
+
resolved = path.resolve(strict=True)
|
|
868
|
+
except (OSError, FileNotFoundError) as error:
|
|
869
|
+
raise ResumeError(f"{description} is missing or unreadable") from error
|
|
870
|
+
if not resolved.is_relative_to(root) or not resolved.is_file():
|
|
871
|
+
raise ResumeError(f"{description} must be a regular file inside the run")
|
|
872
|
+
return resolved
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _load_secure_json(path: Path, *, root: Path) -> dict[str, Any] | None:
|
|
876
|
+
if not path.exists() or path.is_symlink():
|
|
877
|
+
return None
|
|
878
|
+
try:
|
|
879
|
+
resolved = path.resolve(strict=True)
|
|
880
|
+
if not resolved.is_relative_to(root) or not resolved.is_file():
|
|
881
|
+
return None
|
|
882
|
+
payload = json.loads(resolved.read_text())
|
|
883
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
884
|
+
return None
|
|
885
|
+
return payload if isinstance(payload, dict) else None
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _normalized_optional_path(value: str | None) -> str | None:
|
|
889
|
+
return str(Path(value).expanduser().resolve()) if value else None
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _recorded_path(value: str | None, output: Path) -> str | None:
|
|
893
|
+
if value is None:
|
|
894
|
+
return None
|
|
895
|
+
resolved = Path(value).expanduser().resolve()
|
|
896
|
+
if resolved.is_relative_to(output):
|
|
897
|
+
return resolved.relative_to(output).as_posix()
|
|
898
|
+
return str(resolved)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _load_recorded_path(value: Any, root: Path, description: str) -> str | None:
|
|
902
|
+
if value is None:
|
|
903
|
+
return None
|
|
904
|
+
if not isinstance(value, str) or not value:
|
|
905
|
+
raise ResumeError(f"stored {description} path is invalid")
|
|
906
|
+
candidate = Path(value)
|
|
907
|
+
resolved = (root / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
|
|
908
|
+
if not resolved.is_relative_to(root):
|
|
909
|
+
raise ResumeError(f"stored {description} path escapes the run directory")
|
|
910
|
+
return str(resolved)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _automatic_resume_blockers(
|
|
914
|
+
request: MVPLaunchRequest,
|
|
915
|
+
output: Path,
|
|
916
|
+
) -> list[str]:
|
|
917
|
+
blockers: list[str] = []
|
|
918
|
+
for label, value in (
|
|
919
|
+
("ledger", request.ledger),
|
|
920
|
+
("skills directory", request.skills_directory),
|
|
921
|
+
("capability directory", request.capability_directory),
|
|
922
|
+
):
|
|
923
|
+
if value is not None and not Path(value).resolve().is_relative_to(output):
|
|
924
|
+
blockers.append(f"{label} is outside the run directory")
|
|
925
|
+
if request.executable and tuple(request.executable) != default_mvp_executable():
|
|
926
|
+
blockers.append("custom executable is not replayed from an artifact")
|
|
927
|
+
return blockers
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
_CONTRACT_KEYS = frozenset(
|
|
931
|
+
{
|
|
932
|
+
"campaign_id",
|
|
933
|
+
"hypothesis_file",
|
|
934
|
+
"hypothesis_sha256",
|
|
935
|
+
"instruction_file",
|
|
936
|
+
"instruction_sha256",
|
|
937
|
+
"guided_commission",
|
|
938
|
+
"guided_commission_sha256",
|
|
939
|
+
"max_wall_seconds",
|
|
940
|
+
"max_command_seconds",
|
|
941
|
+
"max_workspace_mb",
|
|
942
|
+
"max_file_mb",
|
|
943
|
+
"max_memory_mb",
|
|
944
|
+
"max_iterations",
|
|
945
|
+
"max_tool_output_chars",
|
|
946
|
+
"command_heartbeat_seconds",
|
|
947
|
+
"literature_search_timeout_seconds",
|
|
948
|
+
"recent_full_turns",
|
|
949
|
+
"max_model_retries",
|
|
950
|
+
"model_failover_after",
|
|
951
|
+
"ledger",
|
|
952
|
+
"skills_directory",
|
|
953
|
+
"capability_directory",
|
|
954
|
+
"use_glm",
|
|
955
|
+
"reason",
|
|
956
|
+
"automatic_resume_blockers",
|
|
957
|
+
}
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _assert_launch_record_matches(
|
|
962
|
+
existing: dict[str, Any],
|
|
963
|
+
*,
|
|
964
|
+
desired: dict[str, Any],
|
|
965
|
+
output: Path,
|
|
966
|
+
hypothesis_text: str,
|
|
967
|
+
instruction_text: str | None,
|
|
968
|
+
) -> None:
|
|
969
|
+
schema = existing.get("schema_version")
|
|
970
|
+
if schema == "0.2.0":
|
|
971
|
+
differences = sorted(
|
|
972
|
+
key for key in _CONTRACT_KEYS if existing.get(key) != desired.get(key)
|
|
973
|
+
)
|
|
974
|
+
if differences:
|
|
975
|
+
raise LaunchConflictError(
|
|
976
|
+
"output directory already contains a different launch contract "
|
|
977
|
+
f"({', '.join(differences)})"
|
|
978
|
+
)
|
|
979
|
+
elif schema == "0.1.0":
|
|
980
|
+
legacy_keys = {
|
|
981
|
+
"campaign_id",
|
|
982
|
+
"hypothesis_file",
|
|
983
|
+
"instruction_file",
|
|
984
|
+
"max_wall_seconds",
|
|
985
|
+
"max_command_seconds",
|
|
986
|
+
"max_workspace_mb",
|
|
987
|
+
"max_file_mb",
|
|
988
|
+
"max_memory_mb",
|
|
989
|
+
"max_iterations",
|
|
990
|
+
}
|
|
991
|
+
differences = sorted(
|
|
992
|
+
key for key in legacy_keys if existing.get(key) != desired.get(key)
|
|
993
|
+
)
|
|
994
|
+
advanced_defaults = all(
|
|
995
|
+
desired.get(key) == value
|
|
996
|
+
for key, value in {
|
|
997
|
+
"max_tool_output_chars": 30_000,
|
|
998
|
+
"command_heartbeat_seconds": 30.0,
|
|
999
|
+
"literature_search_timeout_seconds": 20.0,
|
|
1000
|
+
"recent_full_turns": 12,
|
|
1001
|
+
"max_model_retries": 3,
|
|
1002
|
+
"model_failover_after": 2,
|
|
1003
|
+
"ledger": None,
|
|
1004
|
+
"guided_commission": None,
|
|
1005
|
+
"skills_directory": None,
|
|
1006
|
+
"capability_directory": None,
|
|
1007
|
+
"use_glm": False,
|
|
1008
|
+
"reason": None,
|
|
1009
|
+
}.items()
|
|
1010
|
+
)
|
|
1011
|
+
if differences or not advanced_defaults:
|
|
1012
|
+
raise LaunchConflictError(
|
|
1013
|
+
"legacy launch record does not match the requested contract"
|
|
1014
|
+
)
|
|
1015
|
+
else:
|
|
1016
|
+
raise LaunchConflictError(f"unsupported existing launch schema: {schema!r}")
|
|
1017
|
+
|
|
1018
|
+
operator = _secure_operator_directory(output, create=False)
|
|
1019
|
+
hypothesis_path = _contained_regular_file(
|
|
1020
|
+
output,
|
|
1021
|
+
operator / HYPOTHESIS_FILE_NAME,
|
|
1022
|
+
description="stored hypothesis",
|
|
1023
|
+
)
|
|
1024
|
+
if hypothesis_path.read_text() != hypothesis_text:
|
|
1025
|
+
raise LaunchConflictError("stored hypothesis differs from the requested contract")
|
|
1026
|
+
instruction_path = operator / INSTRUCTION_FILE_NAME
|
|
1027
|
+
if instruction_text is None:
|
|
1028
|
+
if instruction_path.exists() or instruction_path.is_symlink():
|
|
1029
|
+
raise LaunchConflictError("stored instruction differs from the requested contract")
|
|
1030
|
+
else:
|
|
1031
|
+
contained = _contained_regular_file(
|
|
1032
|
+
output,
|
|
1033
|
+
instruction_path,
|
|
1034
|
+
description="stored instruction",
|
|
1035
|
+
)
|
|
1036
|
+
if contained.read_text() != instruction_text:
|
|
1037
|
+
raise LaunchConflictError("stored instruction differs from the requested contract")
|
|
1038
|
+
guided_manifest = operator / GUIDED_COMMISSIONING_DIR_NAME / "manifest.json"
|
|
1039
|
+
expected_guided_sha = desired.get("guided_commission_sha256")
|
|
1040
|
+
if expected_guided_sha is None:
|
|
1041
|
+
if guided_manifest.exists() or guided_manifest.is_symlink():
|
|
1042
|
+
raise LaunchConflictError(
|
|
1043
|
+
"unexpected guided commissioning in output directory"
|
|
1044
|
+
)
|
|
1045
|
+
else:
|
|
1046
|
+
contained_guided = _contained_regular_file(
|
|
1047
|
+
output,
|
|
1048
|
+
guided_manifest,
|
|
1049
|
+
description="guided commissioning manifest",
|
|
1050
|
+
)
|
|
1051
|
+
from .mvp_guidance import MVPGuidedCommissioningPackage
|
|
1052
|
+
|
|
1053
|
+
package = MVPGuidedCommissioningPackage.read(contained_guided)
|
|
1054
|
+
if package.package_sha256 != expected_guided_sha:
|
|
1055
|
+
raise LaunchConflictError(
|
|
1056
|
+
"guided commissioning differs from the requested contract"
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _assert_unrecorded_output_is_compatible(
|
|
1061
|
+
output: Path,
|
|
1062
|
+
*,
|
|
1063
|
+
operator: Path,
|
|
1064
|
+
request: MVPLaunchRequest,
|
|
1065
|
+
hypothesis_text: str,
|
|
1066
|
+
instruction_text: str | None,
|
|
1067
|
+
guided_commission_sha256: str | None,
|
|
1068
|
+
) -> None:
|
|
1069
|
+
launch_path = operator / LAUNCH_RECORD_NAME
|
|
1070
|
+
if launch_path.exists() or launch_path.is_symlink():
|
|
1071
|
+
raise LaunchConflictError("existing launch.json is malformed or unsafe")
|
|
1072
|
+
hypothesis_path = operator / HYPOTHESIS_FILE_NAME
|
|
1073
|
+
if hypothesis_path.exists() or hypothesis_path.is_symlink():
|
|
1074
|
+
contained = _contained_regular_file(
|
|
1075
|
+
output,
|
|
1076
|
+
hypothesis_path,
|
|
1077
|
+
description="stored hypothesis",
|
|
1078
|
+
)
|
|
1079
|
+
if contained.read_text() != hypothesis_text:
|
|
1080
|
+
raise LaunchConflictError("stored hypothesis differs from the requested contract")
|
|
1081
|
+
instruction_path = operator / INSTRUCTION_FILE_NAME
|
|
1082
|
+
if instruction_path.exists() or instruction_path.is_symlink():
|
|
1083
|
+
if instruction_text is None:
|
|
1084
|
+
raise LaunchConflictError("unexpected stored instruction in output directory")
|
|
1085
|
+
contained = _contained_regular_file(
|
|
1086
|
+
output,
|
|
1087
|
+
instruction_path,
|
|
1088
|
+
description="stored instruction",
|
|
1089
|
+
)
|
|
1090
|
+
if contained.read_text() != instruction_text:
|
|
1091
|
+
raise LaunchConflictError("stored instruction differs from the requested contract")
|
|
1092
|
+
|
|
1093
|
+
manifest_path = output / "mvp_manifest.json"
|
|
1094
|
+
manifest = _load_secure_json(manifest_path, root=output)
|
|
1095
|
+
if manifest_path.exists() and manifest is None:
|
|
1096
|
+
raise LaunchConflictError("existing MVP manifest is malformed or unsafe")
|
|
1097
|
+
if manifest is not None:
|
|
1098
|
+
if manifest.get("hypothesis") != request.hypothesis or manifest.get(
|
|
1099
|
+
"campaign_instruction"
|
|
1100
|
+
) != request.instruction:
|
|
1101
|
+
raise LaunchConflictError("MVP manifest belongs to a different hypothesis")
|
|
1102
|
+
config = manifest.get("config")
|
|
1103
|
+
expected_config = {
|
|
1104
|
+
"max_iterations": request.max_iterations,
|
|
1105
|
+
"max_wall_seconds": request.max_wall_seconds,
|
|
1106
|
+
"max_command_seconds": request.max_command_seconds,
|
|
1107
|
+
"max_workspace_bytes": request.max_workspace_mb * 1024 * 1024,
|
|
1108
|
+
"max_file_bytes": request.max_file_mb * 1024 * 1024,
|
|
1109
|
+
"max_memory_bytes": request.max_memory_mb * 1024 * 1024,
|
|
1110
|
+
"max_tool_output_chars": request.max_tool_output_chars,
|
|
1111
|
+
"command_heartbeat_seconds": request.command_heartbeat_seconds,
|
|
1112
|
+
"recent_full_turns": request.recent_full_turns,
|
|
1113
|
+
"max_model_retries": request.max_model_retries,
|
|
1114
|
+
"model_failover_after": request.model_failover_after,
|
|
1115
|
+
}
|
|
1116
|
+
if not isinstance(config, dict) or any(
|
|
1117
|
+
config.get(key) != value for key, value in expected_config.items()
|
|
1118
|
+
):
|
|
1119
|
+
raise LaunchConflictError("MVP manifest has a different resource contract")
|
|
1120
|
+
existing_guided = manifest.get("guided_commissioning")
|
|
1121
|
+
existing_guided_sha = (
|
|
1122
|
+
existing_guided.get("package_sha256")
|
|
1123
|
+
if isinstance(existing_guided, dict) and existing_guided
|
|
1124
|
+
else None
|
|
1125
|
+
)
|
|
1126
|
+
if existing_guided_sha != guided_commission_sha256:
|
|
1127
|
+
raise LaunchConflictError(
|
|
1128
|
+
"MVP manifest has different guided commissioning"
|
|
1129
|
+
)
|
|
1130
|
+
from .mvp_skills import (
|
|
1131
|
+
MVPCapabilityRegistry,
|
|
1132
|
+
MVPSkillCatalog,
|
|
1133
|
+
discover_builtin_mvp_resources,
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
skills, capabilities = discover_builtin_mvp_resources()
|
|
1137
|
+
if request.skills_directory:
|
|
1138
|
+
skills = MVPSkillCatalog.discover(request.skills_directory)
|
|
1139
|
+
if request.capability_directory:
|
|
1140
|
+
capabilities = MVPCapabilityRegistry.discover(
|
|
1141
|
+
request.capability_directory
|
|
1142
|
+
)
|
|
1143
|
+
if manifest.get("skill_hashes") != skills.hashes or manifest.get(
|
|
1144
|
+
"capability_hashes"
|
|
1145
|
+
) != capabilities.hashes:
|
|
1146
|
+
raise LaunchConflictError(
|
|
1147
|
+
"MVP manifest has different skill or capability identities"
|
|
1148
|
+
)
|
|
1149
|
+
return
|
|
1150
|
+
|
|
1151
|
+
allowed_operator = {
|
|
1152
|
+
path.name
|
|
1153
|
+
for path in (hypothesis_path, instruction_path)
|
|
1154
|
+
if path.exists()
|
|
1155
|
+
}
|
|
1156
|
+
unexpected_operator = {
|
|
1157
|
+
path.name for path in operator.iterdir() if path.name not in allowed_operator
|
|
1158
|
+
}
|
|
1159
|
+
declared_top_level: set[str] = set()
|
|
1160
|
+
for value in (
|
|
1161
|
+
request.ledger,
|
|
1162
|
+
request.skills_directory,
|
|
1163
|
+
request.capability_directory,
|
|
1164
|
+
):
|
|
1165
|
+
if value is None:
|
|
1166
|
+
continue
|
|
1167
|
+
resolved = Path(value).resolve()
|
|
1168
|
+
if resolved.is_relative_to(output) and resolved != output:
|
|
1169
|
+
declared_top_level.add(resolved.relative_to(output).parts[0])
|
|
1170
|
+
unexpected_output = {
|
|
1171
|
+
path.name
|
|
1172
|
+
for path in output.iterdir()
|
|
1173
|
+
if path != operator and path.name not in declared_top_level
|
|
1174
|
+
}
|
|
1175
|
+
if unexpected_operator or unexpected_output:
|
|
1176
|
+
raise LaunchConflictError(
|
|
1177
|
+
"refusing to initialize a non-empty directory without an MVP manifest"
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _materialize_guided_package(package: Any, manifest_path: Path) -> None:
|
|
1182
|
+
if package.manifest_path == manifest_path:
|
|
1183
|
+
package.assert_identity()
|
|
1184
|
+
return
|
|
1185
|
+
destination = manifest_path.parent
|
|
1186
|
+
if destination.is_symlink():
|
|
1187
|
+
raise LaunchConflictError("guided commissioning destination must not be a symlink")
|
|
1188
|
+
if destination.exists() and any(destination.iterdir()):
|
|
1189
|
+
raise LaunchConflictError("guided commissioning destination is not empty")
|
|
1190
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
1191
|
+
_atomic_write_bytes(manifest_path, package.manifest_path.read_bytes())
|
|
1192
|
+
for record in package.file_records:
|
|
1193
|
+
target = destination / record.path
|
|
1194
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1195
|
+
if target.parent.resolve().is_relative_to(destination.resolve()) is False:
|
|
1196
|
+
raise LaunchConflictError("guided commissioning file escapes its destination")
|
|
1197
|
+
_atomic_write_bytes(target, package.read_file(record.path))
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _sha256_text(text: str) -> str:
|
|
1201
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _read_process_argv(pid: int) -> tuple[str, ...] | None:
|
|
1205
|
+
path = Path(f"/proc/{pid}/cmdline")
|
|
1206
|
+
try:
|
|
1207
|
+
encoded = path.read_bytes()
|
|
1208
|
+
except OSError:
|
|
1209
|
+
return None
|
|
1210
|
+
parts = encoded.rstrip(b"\0").split(b"\0") if encoded else []
|
|
1211
|
+
if not parts:
|
|
1212
|
+
return None
|
|
1213
|
+
return tuple(part.decode(errors="surrogateescape") for part in parts)
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _argv_targets_run(argv: tuple[str, ...], root: Path) -> bool:
|
|
1217
|
+
if "mvp" not in argv:
|
|
1218
|
+
return False
|
|
1219
|
+
mvp_index = argv.index("mvp")
|
|
1220
|
+
prefix = argv[:mvp_index]
|
|
1221
|
+
module_entry = any(
|
|
1222
|
+
item == "-m" and index + 1 < len(prefix) and prefix[index + 1] == "conjecture_solver"
|
|
1223
|
+
for index, item in enumerate(prefix)
|
|
1224
|
+
)
|
|
1225
|
+
console_entry = any(
|
|
1226
|
+
Path(item).name in {"acs", "conjecture-solver", "simjecture"}
|
|
1227
|
+
for item in prefix
|
|
1228
|
+
)
|
|
1229
|
+
if not module_entry and not console_entry:
|
|
1230
|
+
return False
|
|
1231
|
+
output_value: str | None = None
|
|
1232
|
+
for index, item in enumerate(argv):
|
|
1233
|
+
if item == "--output" and index + 1 < len(argv):
|
|
1234
|
+
output_value = argv[index + 1]
|
|
1235
|
+
break
|
|
1236
|
+
if item.startswith("--output="):
|
|
1237
|
+
output_value = item.split("=", 1)[1]
|
|
1238
|
+
break
|
|
1239
|
+
if not output_value:
|
|
1240
|
+
return False
|
|
1241
|
+
try:
|
|
1242
|
+
return Path(output_value).expanduser().resolve() == root
|
|
1243
|
+
except OSError:
|
|
1244
|
+
return False
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def _stat_fields(stat_text: str) -> list[str] | None:
|
|
1248
|
+
close = stat_text.rfind(")")
|
|
1249
|
+
if close < 0:
|
|
1250
|
+
return None
|
|
1251
|
+
return stat_text[close + 2 :].split()
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _starttime_from_stat(stat_text: str) -> str | None:
|
|
1255
|
+
fields = _stat_fields(stat_text)
|
|
1256
|
+
if fields is None or len(fields) < 20:
|
|
1257
|
+
return None
|
|
1258
|
+
return fields[19]
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _state_from_stat_path(pid: int) -> str | None:
|
|
1262
|
+
path = Path(f"/proc/{pid}/stat")
|
|
1263
|
+
if not path.is_file():
|
|
1264
|
+
return None
|
|
1265
|
+
try:
|
|
1266
|
+
fields = _stat_fields(path.read_text())
|
|
1267
|
+
except OSError:
|
|
1268
|
+
return None
|
|
1269
|
+
if not fields:
|
|
1270
|
+
return None
|
|
1271
|
+
return fields[0]
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _atomic_write_bytes(path: Path, encoded: bytes) -> None:
|
|
1275
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1276
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
1277
|
+
temporary = Path(temporary_name)
|
|
1278
|
+
try:
|
|
1279
|
+
with os.fdopen(fd, "wb") as stream:
|
|
1280
|
+
stream.write(encoded)
|
|
1281
|
+
stream.flush()
|
|
1282
|
+
os.fsync(stream.fileno())
|
|
1283
|
+
os.replace(temporary, path)
|
|
1284
|
+
finally:
|
|
1285
|
+
with suppress(FileNotFoundError):
|
|
1286
|
+
temporary.unlink()
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _atomic_write_text(path: Path, text: str) -> None:
|
|
1290
|
+
_atomic_write_bytes(path, text.encode())
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _format_number(value: float) -> str:
|
|
1294
|
+
if value.is_integer():
|
|
1295
|
+
return str(int(value))
|
|
1296
|
+
return repr(value)
|