runops 2.4.0__tar.gz → 2.5.0__tar.gz

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 (139) hide show
  1. {runops-2.4.0 → runops-2.5.0}/PKG-INFO +1 -1
  2. {runops-2.4.0 → runops-2.5.0}/SPEC.md +22 -3
  3. {runops-2.4.0 → runops-2.5.0}/docs/adapter.md +4 -2
  4. {runops-2.4.0 → runops-2.5.0}/docs/guide.md +6 -0
  5. {runops-2.4.0 → runops-2.5.0}/docs/reference.md +5 -3
  6. {runops-2.4.0 → runops-2.5.0}/pyproject.toml +1 -1
  7. {runops-2.4.0 → runops-2.5.0}/src/runops/__init__.py +1 -1
  8. {runops-2.4.0 → runops-2.5.0}/src/runops/cli.py +13 -1
  9. {runops-2.4.0 → runops-2.5.0}/src/runops/manifest.py +8 -0
  10. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/create.py +17 -1
  11. runops-2.5.0/src/runops/ops/progress.py +197 -0
  12. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/skills.py +25 -14
  13. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/sync.py +8 -0
  14. {runops-2.4.0 → runops-2.5.0}/src/runops/skills/runops-check-status/SKILL.md +5 -2
  15. {runops-2.4.0 → runops-2.5.0}/src/runops/skills/runops-create-run/SKILL.md +1 -1
  16. {runops-2.4.0 → runops-2.5.0}/src/runops/skills/runops-reference/SKILL.md +1 -1
  17. runops-2.5.0/tests/test_progress.py +120 -0
  18. {runops-2.4.0 → runops-2.5.0}/tests/test_run_job.py +12 -0
  19. {runops-2.4.0 → runops-2.5.0}/tests/test_skills.py +9 -1
  20. {runops-2.4.0 → runops-2.5.0}/uv.lock +1 -1
  21. {runops-2.4.0 → runops-2.5.0}/.agents/skills/check/SKILL.md +0 -0
  22. {runops-2.4.0 → runops-2.5.0}/.agents/skills/improve-harness/SKILL.md +0 -0
  23. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/SKILL.md +0 -0
  24. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/agents/openai.yaml +0 -0
  25. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/examples/refactor-request.md +0 -0
  26. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/references/anti-patterns.md +0 -0
  27. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/references/api-compatibility.md +0 -0
  28. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/references/packaging-modernization.md +0 -0
  29. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/references/refactor-playbook.md +0 -0
  30. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/references/verification.md +0 -0
  31. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/scripts/api_surface_snapshot.py +0 -0
  32. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/scripts/import_smoke.py +0 -0
  33. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/scripts/inspect_python_package.py +0 -0
  34. {runops-2.4.0 → runops-2.5.0}/.agents/skills/python-package-refactor/scripts/refactor_quality_gate.py +0 -0
  35. {runops-2.4.0 → runops-2.5.0}/.agents/skills/release/SKILL.md +0 -0
  36. {runops-2.4.0 → runops-2.5.0}/.agents/skills/simulator-adapter/SKILL.md +0 -0
  37. {runops-2.4.0 → runops-2.5.0}/.agents/skills/spec-reviewer/SKILL.md +0 -0
  38. {runops-2.4.0 → runops-2.5.0}/.agents/skills/sync-docs/SKILL.md +0 -0
  39. {runops-2.4.0 → runops-2.5.0}/.claude/agents/spec-reviewer.md +0 -0
  40. {runops-2.4.0 → runops-2.5.0}/.claude/rules/architecture.md +0 -0
  41. {runops-2.4.0 → runops-2.5.0}/.claude/rules/dev-workflow.md +0 -0
  42. {runops-2.4.0 → runops-2.5.0}/.claude/rules/gotchas.md +0 -0
  43. {runops-2.4.0 → runops-2.5.0}/.claude/settings.json +0 -0
  44. {runops-2.4.0 → runops-2.5.0}/.claude/skills/check/SKILL.md +0 -0
  45. {runops-2.4.0 → runops-2.5.0}/.claude/skills/improve-harness/SKILL.md +0 -0
  46. {runops-2.4.0 → runops-2.5.0}/.claude/skills/python-package-refactor/SKILL.md +0 -0
  47. {runops-2.4.0 → runops-2.5.0}/.claude/skills/release/SKILL.md +0 -0
  48. {runops-2.4.0 → runops-2.5.0}/.claude/skills/simulator-adapter/SKILL.md +0 -0
  49. {runops-2.4.0 → runops-2.5.0}/.claude/skills/spec-reviewer/SKILL.md +0 -0
  50. {runops-2.4.0 → runops-2.5.0}/.claude/skills/sync-docs/SKILL.md +0 -0
  51. {runops-2.4.0 → runops-2.5.0}/.codex/README.md +0 -0
  52. {runops-2.4.0 → runops-2.5.0}/.codex/agents/spec-reviewer.toml +0 -0
  53. {runops-2.4.0 → runops-2.5.0}/.codex/config.toml +0 -0
  54. {runops-2.4.0 → runops-2.5.0}/.codex/rules/runops.rules +0 -0
  55. {runops-2.4.0 → runops-2.5.0}/.dockerignore +0 -0
  56. {runops-2.4.0 → runops-2.5.0}/.gitattributes +0 -0
  57. {runops-2.4.0 → runops-2.5.0}/.github/assets/conversation-dark.svg +0 -0
  58. {runops-2.4.0 → runops-2.5.0}/.github/assets/conversation-light.svg +0 -0
  59. {runops-2.4.0 → runops-2.5.0}/.github/assets/hero-dark.svg +0 -0
  60. {runops-2.4.0 → runops-2.5.0}/.github/assets/hero-light.svg +0 -0
  61. {runops-2.4.0 → runops-2.5.0}/.github/assets/make_readme_art.py +0 -0
  62. {runops-2.4.0 → runops-2.5.0}/.github/workflows/ci.yml +0 -0
  63. {runops-2.4.0 → runops-2.5.0}/.github/workflows/publish.yml +0 -0
  64. {runops-2.4.0 → runops-2.5.0}/.gitignore +0 -0
  65. {runops-2.4.0 → runops-2.5.0}/.harness/manifest.toml +0 -0
  66. {runops-2.4.0 → runops-2.5.0}/.pre-commit-config.yaml +0 -0
  67. {runops-2.4.0 → runops-2.5.0}/.vscode/settings.json +0 -0
  68. {runops-2.4.0 → runops-2.5.0}/AGENTS.md +0 -0
  69. {runops-2.4.0 → runops-2.5.0}/CLAUDE.md +0 -0
  70. {runops-2.4.0 → runops-2.5.0}/LICENSE +0 -0
  71. {runops-2.4.0 → runops-2.5.0}/README.md +0 -0
  72. {runops-2.4.0 → runops-2.5.0}/src/runops/adapters/__init__.py +0 -0
  73. {runops-2.4.0 → runops-2.5.0}/src/runops/adapters/base.py +0 -0
  74. {runops-2.4.0 → runops-2.5.0}/src/runops/adapters/generic.py +0 -0
  75. {runops-2.4.0 → runops-2.5.0}/src/runops/adapters/registry.py +0 -0
  76. {runops-2.4.0 → runops-2.5.0}/src/runops/case.py +0 -0
  77. {runops-2.4.0 → runops-2.5.0}/src/runops/config.py +0 -0
  78. {runops-2.4.0 → runops-2.5.0}/src/runops/errors.py +0 -0
  79. {runops-2.4.0 → runops-2.5.0}/src/runops/harness.py +0 -0
  80. {runops-2.4.0 → runops-2.5.0}/src/runops/jobgen.py +0 -0
  81. {runops-2.4.0 → runops-2.5.0}/src/runops/launchers.py +0 -0
  82. {runops-2.4.0 → runops-2.5.0}/src/runops/legacy_adapters/__init__.py +0 -0
  83. {runops-2.4.0 → runops-2.5.0}/src/runops/legacy_adapters/beach.py +0 -0
  84. {runops-2.4.0 → runops-2.5.0}/src/runops/legacy_adapters/emses.py +0 -0
  85. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/__init__.py +0 -0
  86. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/common.py +0 -0
  87. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/listing.py +0 -0
  88. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/migrate.py +0 -0
  89. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/notes.py +0 -0
  90. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/setup.py +0 -0
  91. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/submit.py +0 -0
  92. {runops-2.4.0 → runops-2.5.0}/src/runops/ops/tidy.py +0 -0
  93. {runops-2.4.0 → runops-2.5.0}/src/runops/project.py +0 -0
  94. {runops-2.4.0 → runops-2.5.0}/src/runops/runtime.py +0 -0
  95. {runops-2.4.0 → runops-2.5.0}/src/runops/skills/runops-maintain/SKILL.md +0 -0
  96. {runops-2.4.0 → runops-2.5.0}/src/runops/skills/runops-tidy/SKILL.md +0 -0
  97. {runops-2.4.0 → runops-2.5.0}/src/runops/slurm.py +0 -0
  98. {runops-2.4.0 → runops-2.5.0}/src/runops/util.py +0 -0
  99. {runops-2.4.0 → runops-2.5.0}/tests/__init__.py +0 -0
  100. {runops-2.4.0 → runops-2.5.0}/tests/conftest.py +0 -0
  101. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/.codex/rules/a.md +0 -0
  102. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/.runops/test-runs/T20260701-0001/receipt.toml +0 -0
  103. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/.runops/work/beach-x-abc/bin/beach +0 -0
  104. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/AGENTS.md +0 -0
  105. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/CLAUDE.md +0 -0
  106. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/cases/beach/plane/beach.toml +0 -0
  107. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/cases/beach/plane/case.toml +0 -0
  108. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/cases/old/beach.toml +0 -0
  109. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/cases/old/case.toml +0 -0
  110. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/launchers.toml +0 -0
  111. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/research/CURRENT.md +0 -0
  112. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/runops.toml +0 -0
  113. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/runs/AGENTS.md +0 -0
  114. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/runs/_archive/plane/R20260601-0001/manifest.toml +0 -0
  115. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/runs/plane/R20260701-0001--a/input/beach.toml +0 -0
  116. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/runs/plane/R20260701-0001--a/manifest.toml +0 -0
  117. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/runs/plane/R20260702-0001/manifest.toml +0 -0
  118. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/runs/scan/R20260701-0002/manifest.toml +0 -0
  119. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/runs/scan/survey.toml +0 -0
  120. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/simulators.toml +0 -0
  121. {runops-2.4.0 → runops-2.5.0}/tests/fixtures/v1-project/site.toml +0 -0
  122. {runops-2.4.0 → runops-2.5.0}/tests/golden/mpi_standard.sh +0 -0
  123. {runops-2.4.0 → runops-2.5.0}/tests/golden/openmp_rsc.sh +0 -0
  124. {runops-2.4.0 → runops-2.5.0}/tests/test_adapters.py +0 -0
  125. {runops-2.4.0 → runops-2.5.0}/tests/test_archive_series.py +0 -0
  126. {runops-2.4.0 → runops-2.5.0}/tests/test_build.py +0 -0
  127. {runops-2.4.0 → runops-2.5.0}/tests/test_dev_harness.py +0 -0
  128. {runops-2.4.0 → runops-2.5.0}/tests/test_jobgen.py +0 -0
  129. {runops-2.4.0 → runops-2.5.0}/tests/test_legacy_adapters.py +0 -0
  130. {runops-2.4.0 → runops-2.5.0}/tests/test_lifecycle.py +0 -0
  131. {runops-2.4.0 → runops-2.5.0}/tests/test_manifest_config.py +0 -0
  132. {runops-2.4.0 → runops-2.5.0}/tests/test_migrate.py +0 -0
  133. {runops-2.4.0 → runops-2.5.0}/tests/test_mv_naming.py +0 -0
  134. {runops-2.4.0 → runops-2.5.0}/tests/test_project_scope.py +0 -0
  135. {runops-2.4.0 → runops-2.5.0}/tests/test_safety.py +0 -0
  136. {runops-2.4.0 → runops-2.5.0}/tests/test_scratch_tidy.py +0 -0
  137. {runops-2.4.0 → runops-2.5.0}/tests/test_setup_misc.py +0 -0
  138. {runops-2.4.0 → runops-2.5.0}/tests/test_sweep.py +0 -0
  139. {runops-2.4.0 → runops-2.5.0}/tests/test_sync_evidence.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runops
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Run ledger and Slurm job submission for HPC simulation projects
5
5
  Project-URL: Homepage, https://github.com/Nkzono99/runops
6
6
  Project-URL: Documentation, https://github.com/Nkzono99/runops/blob/main/docs/reference.md
@@ -460,6 +460,22 @@ created ──submit──▶ submitted ──(slurm)──▶ running ──▶
460
460
 
461
461
  ---
462
462
 
463
+ ### 6.4 進捗
464
+
465
+ adapter が `progress(run_dir)` を持つとき、`sync` は running の run と、queue を離れた
466
+ 直後の run について呼び、`{done, total, unit, start}` を受け取る。`done` は今の到達点、
467
+ `total` は目標(不明なら省略)、`unit` は数える単位(`batch`、`step` など)、`start` は
468
+ この attempt を始めた地点(resume した run の再開点。新規なら 0)。
469
+
470
+ - 観測は `submit/progress.<job_id>.jsonl` に `{at, done}` として追記する(attempt ごと、
471
+ 上限 1000 件程度)。queue を離れたときの観測は終了時刻で記録し、終了後の待ち時間を速度に含めない。
472
+ - 要約を `[[job.attempts]].progress` に置く: `done`、`total`、`unit`、`start`、`fraction`、
473
+ `rate_recent_per_hour`(直近 10 分)、`rate_attempt_per_hour`(program 開始から。起点は
474
+ `start`)、`eta_hours`(running のみ)。program 開始時刻は job.sh が stdout に出す
475
+ `runops: start` 行から取り、`started_at` に入れる。
476
+ - `status` / `list` は要約だけを読む(file を開かない)。read-only command は観測を足さない。
477
+ - 解析は adapter が行い、runops は保存と表示だけを持つ(§1.2 #4)。
478
+
463
479
  ## 7. CLI
464
480
 
465
481
  ### 7.1 共通規則
@@ -502,7 +518,7 @@ created ──submit──▶ submitted ──(slurm)──▶ running ──▶
502
518
  | `runo case new NAME --simulator SIM [--from CASE]` | case をスキャフォールド。adapter の `case_template()` を展開 |
503
519
  | `runo case list` | case 一覧 |
504
520
  | `runo new CASE [--why TEXT] [--label L] [--set k=v]... [--job k=v]... [--tag T]... [--scratch] [--dest DIR] [--submit] [--created-by WHO] [--json]` | 単発 run を作る |
505
- | `runo new --from RUN [--why] [--set k=v]... [--job k=v]... [--job-from-case] [--continue] [--submit]` | 既存 run の params と `[job]` から派生 run を作る(§5)。`--continue` は adapter が `extend_inputs()` を実装する場合のみ有効(§8.1) |
521
+ | `runo new --from RUN [--why] [--set k=v]... [--job k=v]... [--job-from-case] [--continue] [--submit]` | 既存 run の params と `[job]` から派生 run を作る(§5)。置き場所の既定は元 run と同じ directory(元 run が archive / scratch なら `runs/<case>/`)。`--continue` は adapter が `extend_inputs()` を実装する場合のみ有効(§8.1) |
506
522
  | `runo sweep CASE --set k=v1,v2,... [--set ...] [--names KEY=n1,n2,...]... [--job k=v]... [--label L] [--dest DIR] [--why TEXT] [--apply] [--submit] [--limit N] [--json]` | 直積の候補を表示する。`--apply` で生成。`--submit` は `--apply` と同時のみ有効 |
507
523
  | `runo submit RUN... [--partition P] [--qos Q] [--afterok JOBID] [-y]` | job.sh を生成して sbatch。`created` 以外は拒否 |
508
524
  | `runo submit --all [DIR] [-y]` | DIR 配下の `created` run をすべて投入。件数と概算 core-hours を表示して確認 |
@@ -621,6 +637,7 @@ class SimulatorAdapter(ABC):
621
637
  def agent_context_dir(self) -> Path | None: return None # §8.2
622
638
  def configure(self, sim_config: dict) -> None: ... # runops.toml の [simulators.X] を受け取る
623
639
  def job_prelude(self, run_dir: Path) -> list[str]: return [] # job.sh で実行直前に置く shell 行
640
+ def progress(self, run_dir: Path) -> dict | None: return None # §6.4。{done, total, unit, start}
624
641
  ```
625
642
 
626
643
  - job.sh は run_dir へ `cd` してから実行する(v1 と同じ。v1 adapter の path 規約
@@ -675,8 +692,9 @@ runops は `init` と `update` で次を行う。
675
692
  (例: `mpiemses3d-input-review`)。`runops-` で始まる名前は予約されており展開しない。
676
693
  2. `references/` を `.agents/skills/<adapter>-references/` にコピーし、
677
694
  `SKILL.md` が無ければ reference 一覧を列挙した `SKILL.md` を生成する。
678
- 展開した skill の `../../references/`(package 内の配置での参照)は
679
- `../<adapter>-references/` に書き換える。
695
+ `docs/` があれば同じく `.agents/skills/<adapter>-docs/` に置く。展開した skill の
696
+ `../../references/`、`../../docs/`(package 内の配置での参照)は `../<adapter>-references/`、
697
+ `../<adapter>-docs/` に書き換える。
680
698
  3. 展開先には `.managed-by-runops` marker を置く。marker のある directory は
681
699
  `update` 時に **無条件で上書き** する。marker の無い同名 directory は触らず warning。
682
700
  4. `.runops/skills.lock` に adapter 名、package version、展開した directory、content hash を記録する。
@@ -981,5 +999,6 @@ v2 の文書は次の 4 ファイルだけにする。
981
999
  | 置き換えた系列の整理(2026-10-21) | `archive` が DIR(系列)を受け、`--superseded-by` で置き換え先を manifest に記録する。新しい command と tidy の種別は足さない。skill は新系列を作ったら旧系列の要否を確かめ、不要なら archive して現用系列・archive 先・件数を報告する | 修正版を作った後に旧系列が作業領域に残り、毎回ユーザーが整理を頼んでいた。`parent_run` だけでは比較用の派生と置き換えを区別できないので、置き換えは明示操作で記録する。旧 `archive` は 1 件ずつ検査していて、途中の running run で一部だけ移動していた(#113) |
982
1000
  | 生成ハーネスの書き方(2026-10-21) | AGENTS.md の Rules を「作業の進め方」5 項目に絞り、各項目を「何をするか」と短い理由で書く。skill は手順に専念し、AGENTS.md の方針(manifest を手で編集しない等)を繰り返さない | #112・#113 で規則が増え、禁止形(never / do not)が並んでいた。現行の Claude の prompting 指針は、しないことより何をするかを、理由と共に書くことを勧め、強い禁止語は過剰反応を招くとしている |
983
1001
  | 複数 run の mv / archive(2026-10-22) | 1 回の `run.lock` の下で全対象を読み直して検査し、通ってから移す。途中の filesystem error では移し終えた run id を示して止まる | `mv A B C --dest X` は 1 件ずつ検査して移していたので、途中の submitted run で一部だけ移動していた。archive は表示の後で lock を取り直していて、その間に再投入された run で同じことが起き得た |
1002
+ | 進捗と系列配置 2.5.0(2026-10-23) | adapter に任意の `progress()`、`sync` が時刻付き観測を保存し速度・残り時間を要約(§6.4)。`new --from` の既定置き場所を元 run と同じ directory に。agent context の `docs/` も展開 | 速度比較のために agent が時刻付き batch 観測を手で保存し、再開点や終了後の待ちを毎回補正していた(4 回)。継続世代が case 直下に並び、同じ計算の系列を見分けにくかった。skill が package の `docs/` を参照していたが展開されていなかった。進捗の解析は simulator 固有なので adapter が持ち、runops は保存と表示だけにする |
984
1003
  | FEEDBACK 棚卸し 2.4.0(2026-10-23) | `--job k=v` と `new --from` の `[job]` 継承、created の archive / restore、archived scratch を期限削除しない、展開 skill の references 参照の書き換えと `doctor` のリンク検査、adapter warning への版の併記、生成 AGENTS.md に解析環境の置き場所 | hub の FEEDBACK で、資源変更のための case 編集(3 回)、置換済み draft を archive できず helper で迂回(2 回)、保管目的の archive が期限削除される、skill から reference に辿れない、新版の正規キーが誤記に見える、解析依存の置き場所が project ごとに違う、が記録された。いずれも既存 command の拡張で、command 数は増やさない |
985
1004
  | 使い方ガイド | `docs/guide.md` を追加し、文書を 4 ファイルにする(§13.3) | README の依頼例の表と command 一覧の間を埋める、場面ごとの流れ(case と run の分け方、smoke から本計算、状態の決まり方、整理)の置き場所が無かった |
@@ -49,6 +49,7 @@ class Adapter:
49
49
  | `keep_patterns() -> list[str]` | `tidy --purge-work` で残す glob |
50
50
  | `extend_inputs(src_run, run_dir, params) -> list[str]` | `runo new --from RUN --continue` の restart 設定 |
51
51
  | `agent_context_dir() -> Path | None` | 同梱 skill と reference の場所 |
52
+ | `progress(run_dir) -> dict | None` | 進捗 `{"done", "total", "unit", "start"}`。`runo sync` が時刻付きで保存し、速度と残り時間を出す |
52
53
 
53
54
  `runtime` は `executable`、`package_version`、`exe_hash`、`git_commit`、`git_dirty`、
54
55
  `env`、`resolver_mode`、`build_dir` を持ちます。
@@ -76,14 +77,15 @@ project の `adapters/mysim.py` に `Adapter` という名前で置けば使え
76
77
  <dir>/
77
78
  skills/<skill-name>/SKILL.md
78
79
  references/*.md
80
+ docs/*.md # 任意。references と同じく展開する
79
81
  ```
80
82
 
81
83
  `runo init` と `runo update` は `skills/*` を project の `.agents/skills/` と
82
84
  `.claude/skills/` に同名でコピーし、`references/` を `<adapter>-references` として置きます。
83
85
  skill 名は package 側で名前空間を付けてください(例: `mysim-input-review`)。
84
86
  `runops-` で始まる名前は予約されています。skill から reference へは
85
- `../<adapter>-references/X.md` と書いてください。package 内の配置どおりの
86
- `../../references/X.md` は展開時にこの形へ書き換えます。`runo doctor` は展開済み skill の
87
+ `../<adapter>-references/X.md`(`docs/` なら `../<adapter>-docs/X.md`)と書いてください。
88
+ package 内の配置どおりの `../../references/X.md`、`../../docs/X.md` は展開時にこの形へ書き換えます。`runo doctor` は展開済み skill の
87
89
  相対リンク切れを報告します。展開した directory には
88
90
  `.managed-by-runops` が置かれ、更新時に上書きされます。利用者が作った同名 directory は
89
91
  上書きしません。
@@ -131,6 +131,11 @@ walltime は、同じ case・同じ資源で終わった run の実時間を元
131
131
  `runo sync` は、active な run が何件あっても `squeue` と `sacct` を 1 回ずつしか呼びません。
132
132
  エージェントには `squeue` を直接叩かせず、`runo sync` を使わせてください。
133
133
 
134
+ adapter が進捗に対応していれば(BEACH の batch、MPIEMSES3D の step)、`sync` のたびに時刻付きの
135
+ 観測を残し、`runo list` / `status` に `62% 0.198 s/batch eta 3.1h` のように進み具合、直近 10 分の
136
+ 速度、残り時間を出します。resume した run は再開点から数え、終わった run は終了時刻までで速度を出すので、
137
+ 版を切り替えた前後の比較にそのまま使えます(`--json` の `progress`)。
138
+
134
139
  ### 状態の決まり方
135
140
 
136
141
  run の状態は `created → submitted → running → completed | failed | cancelled` と進みます。
@@ -180,6 +185,7 @@ project の run だけが対象です。同じアカウントの別 project の
180
185
  > R20261015-0003 の続きを 2 倍の step で。
181
186
 
182
187
  `runo new --from RUN --continue` で、終わった run の出力から再開する run を作ります。
188
+ `--from` で作る run は元 run と同じ directory に置かれるので、同じ計算の継続は 1 つの directory に並びます。
183
189
  再開の方法は simulator ごとに違い、adapter が対応している場合だけ使えます
184
190
  (現在は MPIEMSES3D が対応し、`SNAPSHOT1` から再開します)。新しい run は元の run を `parent_run` として記録します。
185
191
 
@@ -110,7 +110,7 @@ runops だけが書きます。手で編集しないでください。主な fie
110
110
  | `[path]` | `run_dir`(project 相対)、`archived_from`、`archived_at` |
111
111
  | `[origin]` | `case`、`sweep`、`parent_run`、`created_by`、`case_input_hash` |
112
112
  | `[simulator]` | `name`、`adapter`、`adapter_source`、`adapter_version`、`executable`、`resolver_mode`、`package_version`、`exe_hash`、`git_commit`、`git_dirty`、`build_dir` |
113
- | `[job]` | case の `[job]` に加え `job_id`、`attempt`、`submitted_at`、`finished_at`、`last_slurm_state`、`exit_code`、`[[job.attempts]]`(attempt ごとの `job_id`、`started_at`、`exit_code`、`exe_hash`、`elapsed`、`cancel --why` の `cancel_reason`) |
113
+ | `[job]` | case の `[job]` に加え `job_id`、`attempt`、`submitted_at`、`finished_at`、`last_slurm_state`、`exit_code`、`[[job.attempts]]`(attempt ごとの `job_id`、`started_at`、`exit_code`、`exe_hash`、`elapsed`、`cancel --why` の `cancel_reason`、`runo sync` が付ける `progress`(§進捗)) |
114
114
  | `[params]` | case の params に `--set` を適用した最終値 |
115
115
  | `[variation]` | `changed_keys`、`job_changed_keys`(case の `[job]` と違う key) |
116
116
  | `[identity]` | `input_hash` |
@@ -139,7 +139,9 @@ run id は削除後も再利用しません。`--dest` に既存 run の中は
139
139
  ## command
140
140
 
141
141
  `list --json` と `status --json` は、walltime の根拠として `walltime`、最新 attempt の `elapsed`、
142
- `walltime_used`(elapsed / walltime)、`slurm_state` も返します。表では `TIMEOUT` などを
142
+ `walltime_used`(elapsed / walltime)、`slurm_state` も返します。adapter が `progress()` を持てば
143
+ `progress`(`done`、`total`、`unit`、`fraction`、`rate_recent_per_hour`、`rate_attempt_per_hour`、
144
+ `eta_hours`)も返し、表の `progress` 列に `62% 0.198 s/batch eta 3.1h` と出します。表では `TIMEOUT` などを
143
145
  `failed (TIMEOUT)` と示します。
144
146
 
145
147
  全 command は `--help` で option を確認できます。`RUN` には run id、run directory、
@@ -154,7 +156,7 @@ run id は削除後も再利用しません。`--dest` に既存 run の中は
154
156
  | `runo context [--json]` | agent 向けの現在地 |
155
157
  | `runo case new NAME [-s SIM] [--from CASE]` / `runo case list` | case の作成と一覧 |
156
158
  | `runo new CASE [--why] [--label] [--set k=v]... [--job k=v]... [--tag] [--scratch] [--dest DIR] [--submit] [--json]` | run を 1 つ作る。`--job` はこの run の `[job]` の値 |
157
- | `runo new --from RUN [--set k=v]... [--job k=v]... [--job-from-case] [--continue]` | 既存 run の params と `[job]` から派生(`--job-from-case` で `[job]` は case から)。`--continue` は adapter の restart 対応が必要 |
159
+ | `runo new --from RUN [--set k=v]... [--job k=v]... [--job-from-case] [--continue]` | 既存 run の params と `[job]` から派生(`--job-from-case` で `[job]` は case から)。置き場所の既定は元 run と同じ directory。`--continue` は adapter の restart 対応が必要 |
158
160
  | `runo sweep CASE --set k=v1,v2 ... [--names k=n1,n2...] [--job k=v]... [--label] [--dest DIR] [--limit N] [--apply] [--submit] [--json]` | 直積の候補表示。`--apply` で作成。`--names` は各値の短い label、`--dest` は sweep directory の親 |
159
161
  | `runo submit RUN... [-p P] [--qos Q] [--afterok JOB]` / `runo submit --all [DIR] [-y]` | job.sh を生成して sbatch |
160
162
  | `runo cancel RUN... [--why TEXT] [-y]` / `runo retry RUN [--submit] [-y]` | scancel(`--why` は note に残る)/ failed・cancelled を created に戻す(理由付きで cancel された run は確認を取る) |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runops"
7
- version = "2.4.0"
7
+ version = "2.5.0"
8
8
  description = "Run ledger and Slurm job submission for HPC simulation projects"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,3 +1,3 @@
1
1
  """runops: run ledger and Slurm job submission for HPC simulation projects."""
2
2
 
3
- __version__ = "2.4.0"
3
+ __version__ = "2.5.0"
@@ -592,9 +592,20 @@ def retry(
592
592
 
593
593
  def _status_table(project: Project, manifests: list[Manifest]) -> Table:
594
594
  from runops.ops.listing import relative_age
595
+ from runops.ops.progress import format_progress
595
596
 
596
597
  table = Table(
597
- "id", "status", "label", "case", "sweep", "created", "elapsed", "note", "why", box=None
598
+ "id",
599
+ "status",
600
+ "label",
601
+ "case",
602
+ "sweep",
603
+ "created",
604
+ "elapsed",
605
+ "progress",
606
+ "note",
607
+ "why",
608
+ box=None,
598
609
  )
599
610
  for m in manifests:
600
611
  s = m.to_summary(project.root)
@@ -625,6 +636,7 @@ def _status_table(project: Project, manifests: list[Manifest]) -> Table:
625
636
  s["sweep"],
626
637
  relative_age(s["created_at"]),
627
638
  elapsed,
639
+ format_progress(s["progress"]),
628
640
  s["last_note"][:60],
629
641
  s["why"][:60],
630
642
  )
@@ -220,8 +220,16 @@ class Manifest:
220
220
  "input_hash": str(self.section("identity").get("input_hash", "")),
221
221
  # Walltime evidence for the next run: elapsed of the latest attempt.
222
222
  **self.timing(),
223
+ "progress": self.progress,
223
224
  }
224
225
 
226
+ @property
227
+ def progress(self) -> dict[str, Any] | None:
228
+ """Progress of the latest attempt as `runo sync` last saw it (adapter ``progress()``)."""
229
+ attempts = self.job.get("attempts") or []
230
+ value = attempts[-1].get("progress") if attempts else None
231
+ return dict(value) if isinstance(value, dict) else None
232
+
225
233
  def timing(self) -> dict[str, Any]:
226
234
  """Walltime, latest attempt elapsed, the fraction used, and the last Slurm state."""
227
235
  walltime = str(self.job.get("walltime", ""))
@@ -26,7 +26,7 @@ from runops.errors import RunError, UsageError
26
26
  from runops.manifest import MANIFEST_FILE, SCHEMA_VERSION, Manifest
27
27
  from runops.ops.common import SimContext, resolve_dest, sim_context
28
28
  from runops.ops.submit import write_job_for
29
- from runops.project import SCRATCH_DIR, Project
29
+ from runops.project import ARCHIVE_DIR, SCRATCH_DIR, Project
30
30
  from runops.runtime import resolve_runtime
31
31
  from runops.util import hash_tree, move_noreplace, now_iso, parse_value
32
32
 
@@ -190,6 +190,18 @@ def _dest_parent(project: Project, case: Case, dest: str, scratch: bool) -> Path
190
190
  return resolve_dest(project, dest)
191
191
 
192
192
 
193
+ def _beside(project: Project, source: Manifest) -> Path | None:
194
+ """The source run's directory under ``runs/``, unless it is archived or scratch."""
195
+ parent = source.run_dir.resolve().parent
196
+ runs = project.runs_dir.resolve()
197
+ if parent != runs and runs not in parent.parents:
198
+ return None
199
+ first = parent.relative_to(runs).parts[:1]
200
+ if first and first[0] in (ARCHIVE_DIR, SCRATCH_DIR):
201
+ return None
202
+ return parent
203
+
204
+
193
205
  def create_run(project: Project, request: CreateRequest) -> CreateResult:
194
206
  """Create one run from a case, or derived from another run (``--from``)."""
195
207
  source: Manifest | None = None
@@ -226,6 +238,10 @@ def create_run(project: Project, request: CreateRequest) -> CreateResult:
226
238
  job = dict(case.job)
227
239
  job.update(request.job)
228
240
  parent = _dest_parent(project, case, request.dest, request.scratch)
241
+ if source is not None and not request.dest and not request.scratch:
242
+ # A derived or continued run sits beside its source, so one series (e.g. the
243
+ # restarts of one calculation) stays in one directory.
244
+ parent = _beside(project, source) or parent
229
245
  spec = RunSpec(
230
246
  params=params,
231
247
  changed=changed,
@@ -0,0 +1,197 @@
1
+ """Progress samples from the adapter's optional ``progress()`` hook (SPEC §6.4, §8.1).
2
+
3
+ ``runo sync`` asks the adapter how far each active run is (``done`` of ``total`` in some
4
+ ``unit``) and appends a timestamped sample to ``submit/progress.<job_id>.jsonl``. From the
5
+ samples it derives, per attempt, the recent rate, the rate since the program started
6
+ (counting from the adapter's ``start``, e.g. the batch a resumed run began at) and the
7
+ remaining time, and keeps that summary in ``[[job.attempts]].progress`` so ``status`` and
8
+ ``list`` show it without reading files.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from runops.adapters.registry import optional
19
+ from runops.errors import RunopsError
20
+ from runops.manifest import Manifest
21
+ from runops.ops.common import run_sim_context
22
+ from runops.project import Project
23
+ from runops.util import parse_iso
24
+
25
+ SAMPLES_KEPT = 1000 # per attempt; older samples are dropped when the file doubles
26
+ RECENT_WINDOW = timedelta(minutes=10)
27
+ _START_MARK = "runops: start "
28
+
29
+
30
+ def samples_path(run_dir: Path, job_id: str) -> Path:
31
+ return run_dir / "submit" / f"progress.{job_id}.jsonl"
32
+
33
+
34
+ def read_progress(project: Project, manifest: Manifest) -> dict[str, Any] | None:
35
+ """The adapter's ``progress(run_dir)`` as ``{done, total, unit, start}``, or ``None``."""
36
+ try:
37
+ ctx = run_sim_context(project, manifest)
38
+ raw = optional(ctx.adapter, "progress", None, manifest.run_dir)
39
+ except (RunopsError, OSError, ValueError):
40
+ return None
41
+ if not isinstance(raw, dict) or not isinstance(raw.get("done"), (int, float)):
42
+ return None
43
+ total = raw.get("total")
44
+ start = raw.get("start")
45
+ return {
46
+ "done": raw["done"],
47
+ "total": total if isinstance(total, (int, float)) and total > 0 else None,
48
+ "unit": str(raw.get("unit") or "step"),
49
+ "start": start if isinstance(start, (int, float)) else None,
50
+ }
51
+
52
+
53
+ def record_progress(
54
+ project: Project, manifest: Manifest, *, at: datetime, final: bool = False
55
+ ) -> None:
56
+ """Append one sample for the latest attempt and refresh its summary in *manifest*.
57
+
58
+ Call under ``run.lock``; the caller saves the manifest. *final* marks the sample
59
+ taken when the run left the queue; *at* is then its finish time, so the rate does
60
+ not include the time after the job ended.
61
+ """
62
+ attempts = manifest.job.get("attempts") or []
63
+ if not attempts or not manifest.job_id:
64
+ return
65
+ current = read_progress(project, manifest)
66
+ if current is None:
67
+ return
68
+ path = samples_path(manifest.run_dir, manifest.job_id)
69
+ samples = _read_samples(path)
70
+ samples.append({"at": at.isoformat(timespec="seconds"), "done": current["done"]})
71
+ _write_samples(path, samples)
72
+ attempt = attempts[-1]
73
+ if not attempt.get("started_at"):
74
+ started = program_start(manifest)
75
+ if started:
76
+ attempt["started_at"] = started
77
+ attempt["progress"] = summarize(
78
+ samples,
79
+ total=current["total"],
80
+ unit=current["unit"],
81
+ start=current["start"],
82
+ started_at=parse_iso(str(attempt.get("started_at", ""))),
83
+ final=final,
84
+ )
85
+
86
+
87
+ def summarize(
88
+ samples: list[dict[str, Any]],
89
+ *,
90
+ total: float | None,
91
+ unit: str,
92
+ start: float | None,
93
+ started_at: datetime | None,
94
+ final: bool = False,
95
+ ) -> dict[str, Any]:
96
+ points = [(t, float(s["done"])) for s in samples if (t := parse_iso(str(s.get("at", ""))))]
97
+ last_at, done = points[-1]
98
+ raw_done = samples[-1]["done"]
99
+ # Origin for the attempt's average: the program start and the adapter's start value
100
+ # (a resumed run starts at its checkpoint, not at 0); else the first sample.
101
+ if started_at is not None and start is not None and started_at <= last_at:
102
+ origin = (started_at, float(start))
103
+ else:
104
+ origin = points[0]
105
+ earlier = [p for p in points[:-1] if p[0] <= last_at - RECENT_WINDOW]
106
+ reference = earlier[-1] if earlier else (points[0] if len(points) > 1 else origin)
107
+ rate_attempt = _rate(origin, points[-1])
108
+ rate_recent = _rate(reference, points[-1]) or rate_attempt
109
+ summary: dict[str, Any] = {
110
+ "done": raw_done,
111
+ "total": total,
112
+ "unit": unit,
113
+ "at": last_at.isoformat(timespec="seconds"),
114
+ "fraction": round(done / total, 4) if total else None,
115
+ "rate_recent_per_hour": _round(rate_recent),
116
+ "rate_attempt_per_hour": _round(rate_attempt),
117
+ "eta_hours": None,
118
+ }
119
+ if start is not None:
120
+ summary["start"] = start
121
+ if not final and total and rate_recent:
122
+ summary["eta_hours"] = round(max(total - done, 0.0) / rate_recent, 2)
123
+ # TOML has no null: leave unknown values out.
124
+ return {k: v for k, v in summary.items() if v is not None}
125
+
126
+
127
+ def program_start(manifest: Manifest) -> str:
128
+ """When the program of the latest job started: job.sh prints it in stdout."""
129
+ from runops.ops.sync import log_paths
130
+
131
+ stdout, _ = log_paths(manifest)
132
+ if stdout is None:
133
+ return ""
134
+ try:
135
+ with stdout.open("rb") as handle:
136
+ head = handle.read(8192).decode("utf-8", errors="replace")
137
+ except OSError:
138
+ return ""
139
+ for line in head.splitlines():
140
+ if line.startswith(_START_MARK):
141
+ stamp = line[len(_START_MARK) :].split()[0]
142
+ return stamp if parse_iso(stamp) else ""
143
+ return ""
144
+
145
+
146
+ def format_progress(progress: dict[str, Any] | None) -> str:
147
+ """Short text for tables: ``62% 0.198 s/batch eta 3.1h``."""
148
+ if not progress:
149
+ return ""
150
+ parts = []
151
+ if progress.get("fraction") is not None:
152
+ parts.append(f"{progress['fraction']:.0%}")
153
+ else:
154
+ parts.append(f"{progress['done']} {progress['unit']}")
155
+ rate = progress.get("rate_recent_per_hour")
156
+ if rate:
157
+ parts.append(f"{3600 / rate:.3g} s/{progress['unit']}")
158
+ if progress.get("eta_hours") is not None:
159
+ parts.append(f"eta {progress['eta_hours']:.1f}h")
160
+ return " ".join(parts)
161
+
162
+
163
+ def _rate(a: tuple[datetime, float], b: tuple[datetime, float]) -> float | None:
164
+ hours = (b[0] - a[0]).total_seconds() / 3600
165
+ if hours <= 0 or b[1] < a[1]:
166
+ return None
167
+ return (b[1] - a[1]) / hours
168
+
169
+
170
+ def _round(value: float | None) -> float | None:
171
+ return float(f"{value:.4g}") if value else None
172
+
173
+
174
+ def _read_samples(path: Path) -> list[dict[str, Any]]:
175
+ try:
176
+ lines = path.read_text(encoding="utf-8").splitlines()
177
+ except OSError:
178
+ return []
179
+ out = []
180
+ for line in lines:
181
+ try:
182
+ item = json.loads(line)
183
+ except json.JSONDecodeError:
184
+ continue
185
+ if isinstance(item, dict) and "at" in item and "done" in item:
186
+ out.append(item)
187
+ return out
188
+
189
+
190
+ def _write_samples(path: Path, samples: list[dict[str, Any]]) -> None:
191
+ if len(samples) > 2 * SAMPLES_KEPT:
192
+ samples = samples[-SAMPLES_KEPT:]
193
+ path.write_text("".join(json.dumps(s) + "\n" for s in samples), encoding="utf-8")
194
+ return
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+ with path.open("a", encoding="utf-8") as handle:
197
+ handle.write(json.dumps(samples[-1]) + "\n")
@@ -69,8 +69,9 @@ def adapter_sources(project: Project, warnings: list[str]) -> list[SkillSource]:
69
69
  )
70
70
  continue
71
71
  skills[path.name] = path # the package owns its skill namespace
72
- if (directory / "references").is_dir():
73
- skills[f"{sim.adapter}-references"] = directory / "references"
72
+ for folder in _DOC_FOLDERS:
73
+ if (directory / folder).is_dir():
74
+ skills[f"{sim.adapter}-{folder}"] = directory / folder
74
75
  version = ctx.resolved.version or "local"
75
76
  sources.append(SkillSource(sim.adapter, version, skills))
76
77
  return sources
@@ -104,8 +105,8 @@ def deploy(project: Project, sources: list[SkillSource]) -> DeployReport:
104
105
  shutil.copytree(src, target)
105
106
  if not (target / "SKILL.md").is_file():
106
107
  _write_reference_index(target, source.source)
107
- elif f"{source.source}-references" in source.skills:
108
- _point_at_deployed_references(target, source.source)
108
+ else:
109
+ _point_at_deployed_documents(target, source)
109
110
  (target / MARKER).write_text(
110
111
  f'source = "{source.source}"\nversion = "{source.version}"\n',
111
112
  encoding="utf-8",
@@ -160,18 +161,27 @@ def _marker_source(marker: Path) -> str:
160
161
  return ""
161
162
 
162
163
 
163
- # A package skill sits in <agent_context>/skills/<name>/ and often reaches its documents as
164
- # ../../references/; once deployed, the documents live in the sibling <adapter>-references/.
165
- _PACKAGE_REFERENCES = "../../references/"
164
+ # Document folders an agent context may hold beside skills/. Each is deployed as the
165
+ # skill directory <adapter>-<folder>/ (with a generated SKILL.md index).
166
+ _DOC_FOLDERS = ("references", "docs")
166
167
 
167
168
 
168
- def _point_at_deployed_references(target: Path, source: str) -> None:
169
+ def _point_at_deployed_documents(target: Path, source: SkillSource) -> None:
170
+ """Rewrite ../../<folder>/ (the package layout) to ../<adapter>-<folder>/ (deployed)."""
171
+ moves = {
172
+ f"../../{folder}/": f"../{source.source}-{folder}/"
173
+ for folder in _DOC_FOLDERS
174
+ if f"{source.source}-{folder}" in source.skills
175
+ }
176
+ if not moves:
177
+ return
169
178
  for md in target.rglob("*.md"):
170
179
  text = md.read_text(encoding="utf-8")
171
- if _PACKAGE_REFERENCES in text:
172
- md.write_text(
173
- text.replace(_PACKAGE_REFERENCES, f"../{source}-references/"), encoding="utf-8"
174
- )
180
+ new = text
181
+ for old, replacement in moves.items():
182
+ new = new.replace(old, replacement)
183
+ if new != text:
184
+ md.write_text(new, encoding="utf-8")
175
185
 
176
186
 
177
187
  _LINK = re.compile(r"\]\(([^)\s]+)\)|`(\.\.?/[^`\s]+)`")
@@ -201,14 +211,15 @@ def broken_skill_links(project: Project) -> dict[str, list[str]]:
201
211
 
202
212
  def _write_reference_index(target: Path, source: str) -> None:
203
213
  files = sorted(p.relative_to(target).as_posix() for p in target.rglob("*") if p.is_file())
214
+ kind = target.name.removeprefix(f"{source}-") # references | docs
204
215
  lines = [
205
216
  "---",
206
217
  f"name: {target.name}",
207
- f"description: Reference documents for the {source} simulator. "
218
+ f"description: {kind.capitalize()} documents for the {source} simulator. "
208
219
  "Read the file that matches the question.",
209
220
  "---",
210
221
  "",
211
- f"# {source} references",
222
+ f"# {source} {kind}",
212
223
  "",
213
224
  *[f"- [{name}]({name})" for name in files],
214
225
  "",
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
+ from datetime import datetime
6
7
  from pathlib import Path
7
8
  from typing import Any
8
9
 
@@ -10,6 +11,7 @@ from runops.errors import RunopsError, UsageError
10
11
  from runops.jobgen import exit_record_path
11
12
  from runops.manifest import ACTIVE_STATES, Manifest, RunState
12
13
  from runops.ops.common import run_sim_context
14
+ from runops.ops.progress import record_progress
13
15
  from runops.project import Project
14
16
  from runops.slurm import CommandRunner, JobState, default_runner, query, query_many
15
17
  from runops.util import format_duration, load_toml, now_iso, parse_duration, parse_iso, tomllib
@@ -112,6 +114,12 @@ def _settle(project: Project, manifest: Manifest, job: JobState | None) -> SyncR
112
114
  elapsed = _elapsed(job, record)
113
115
  if elapsed:
114
116
  attempts[-1]["elapsed"] = elapsed
117
+ # The last sample is dated at the finish, so the rate ends where the job did.
118
+ record_progress(
119
+ project, manifest, at=parse_iso(now) or datetime.now().astimezone(), final=True
120
+ )
121
+ elif new_state == RunState.RUNNING:
122
+ record_progress(project, manifest, at=datetime.now().astimezone())
115
123
  manifest.set_status(new_state)
116
124
  manifest.save()
117
125
  return SyncResult(manifest.id, before.value, new_state.value, slurm_state, message)
@@ -9,12 +9,15 @@ description: Check the progress or result of runops runs (sync, list, log, retry
9
9
  2. `runo list` shows active and recent runs. `--all`, `--case C`, `--status failed`
10
10
  and `--json` narrow or widen the view. Scratch and archived runs are hidden unless
11
11
  `--scratch` / `--archived` is given.
12
- 3. `runo status RUN` shows one run; `runo log RUN` and `runo log RUN --err` show the
12
+ 3. `runo status RUN` shows one run, with progress, recent speed and time left when the
13
+ adapter reports progress (`--json` → `progress`; counted from the resume point, ending
14
+ at the finish). Use it for speed comparisons instead of timing logs by hand.
15
+ `runo log RUN` and `runo log RUN --err` show the
13
16
  tail of the latest job's stdout / stderr (`-n 200` for more, `-f` to follow).
14
17
  4. For a failed run, read the stderr log and the adapter summary
15
18
  (`runo summarize RUN`). Record the cause with `runo note RUN "..."`.
16
19
  `failed (TIMEOUT)` means the walltime ran out: compare `elapsed` with `walltime`
17
- and raise the case walltime for the next run.
20
+ and give the next run more with `--job walltime=...`.
18
21
  5. To rerun the same inputs: `runo retry RUN` then `runo submit RUN` after confirming.
19
22
  To change inputs, create a new run with `runo new --from RUN --set ...`.
20
23
  6. Recovery ("jobs vanished, resubmit them"): `runo sync`, then
@@ -9,7 +9,7 @@ description: Create simulation runs with runops (runo new / new --from / sweep).
9
9
  2. Pick the case. Express the variation with `--set`, not with a new case:
10
10
  - one run: `runo new CASE --why "..." --set section.key=value`
11
11
  - from an existing run: `runo new --from RUN --why "..." --set key=value`
12
- (keeps the source's params and `[job]`; the output lists what differs)
12
+ (keeps the source's params and `[job]`, and sits beside it; the output lists what differs)
13
13
  - continue from a finished run (if the adapter supports it): `runo new --from RUN --continue --why "..."`
14
14
  - a product of values: `runo sweep CASE --set a=1,2 --set b=x,y --why "..."`
15
15
  shows the points and core-hours without creating anything; add `--apply` to create.
@@ -46,6 +46,6 @@ from the cwd; paths into another project's `runs/` are refused.
46
46
 
47
47
  `[run]` id, label, status, why, scratch, expires_at, pinned, tags, superseded_by, `[[notes]]`;
48
48
  `[origin]` case, sweep, parent_run; `[simulator]` executable and provenance;
49
- `[job]` resources, job_id, `[[job.attempts]]` (job_id, exit_code, elapsed, cancel_reason); `[params]` final values;
49
+ `[job]` resources, job_id, `[[job.attempts]]` (job_id, exit_code, elapsed, cancel_reason, progress); `[params]` final values;
50
50
  `[variation].changed_keys`, `job_changed_keys`. Status: created -> submitted -> running ->
51
51
  completed | failed | cancelled; terminal runs can be archived.