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.
Files changed (75) hide show
  1. conjecture_solver/__init__.py +10 -0
  2. conjecture_solver/__main__.py +4 -0
  3. conjecture_solver/action_handlers.py +459 -0
  4. conjecture_solver/adapters/__init__.py +7 -0
  5. conjecture_solver/adapters/base.py +96 -0
  6. conjecture_solver/adapters/fake.py +155 -0
  7. conjecture_solver/adapters/pic.py +196 -0
  8. conjecture_solver/adapters/warpx.py +1375 -0
  9. conjecture_solver/autonomous_research.py +1071 -0
  10. conjecture_solver/benchmarks/__init__.py +6 -0
  11. conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
  12. conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
  13. conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
  14. conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
  15. conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
  16. conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
  17. conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
  18. conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
  19. conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
  20. conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
  21. conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
  22. conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
  23. conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
  24. conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
  25. conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
  26. conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
  27. conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
  28. conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
  29. conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
  30. conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
  31. conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
  32. conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
  33. conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
  34. conjecture_solver/campaign.py +406 -0
  35. conjecture_solver/cli.py +963 -0
  36. conjecture_solver/confirmation.py +287 -0
  37. conjecture_solver/control.py +234 -0
  38. conjecture_solver/deployment.py +874 -0
  39. conjecture_solver/discovery.py +68 -0
  40. conjecture_solver/domains/__init__.py +40 -0
  41. conjecture_solver/domains/base.py +157 -0
  42. conjecture_solver/domains/kinetic_sufficiency.py +521 -0
  43. conjecture_solver/ledger.py +238 -0
  44. conjecture_solver/lifecycle.py +131 -0
  45. conjecture_solver/literature.py +494 -0
  46. conjecture_solver/llm.py +228 -0
  47. conjecture_solver/models.py +279 -0
  48. conjecture_solver/mvp_agent.py +3212 -0
  49. conjecture_solver/mvp_claims.py +1279 -0
  50. conjecture_solver/mvp_control.py +322 -0
  51. conjecture_solver/mvp_guidance.py +172 -0
  52. conjecture_solver/mvp_launch.py +1296 -0
  53. conjecture_solver/mvp_monitor.py +1487 -0
  54. conjecture_solver/mvp_skills.py +538 -0
  55. conjecture_solver/orchestration.py +578 -0
  56. conjecture_solver/outbox.py +526 -0
  57. conjecture_solver/parameters.py +394 -0
  58. conjecture_solver/proposals.py +427 -0
  59. conjecture_solver/research_tools.py +278 -0
  60. conjecture_solver/schema_export.py +246 -0
  61. conjecture_solver/search.py +1047 -0
  62. conjecture_solver/semantics.py +146 -0
  63. conjecture_solver/tui/__init__.py +7 -0
  64. conjecture_solver/tui/app.py +202 -0
  65. conjecture_solver/tui/claim_views.py +213 -0
  66. conjecture_solver/tui/screens.py +1014 -0
  67. conjecture_solver/warpx_analysis.py +184 -0
  68. conjecture_solver/warpx_campaign.py +148 -0
  69. conjecture_solver/warpx_confirmation.py +617 -0
  70. simjecture-0.1.0.dist-info/METADATA +400 -0
  71. simjecture-0.1.0.dist-info/RECORD +75 -0
  72. simjecture-0.1.0.dist-info/WHEEL +4 -0
  73. simjecture-0.1.0.dist-info/entry_points.txt +4 -0
  74. simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
  75. 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)