runops 2.2.0__tar.gz → 2.3.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 (136) hide show
  1. {runops-2.2.0 → runops-2.3.0}/PKG-INFO +3 -1
  2. {runops-2.2.0 → runops-2.3.0}/README.md +2 -0
  3. {runops-2.2.0 → runops-2.3.0}/SPEC.md +17 -7
  4. {runops-2.2.0 → runops-2.3.0}/docs/guide.md +20 -1
  5. {runops-2.2.0 → runops-2.3.0}/docs/reference.md +7 -4
  6. {runops-2.2.0 → runops-2.3.0}/pyproject.toml +1 -1
  7. {runops-2.2.0 → runops-2.3.0}/src/runops/__init__.py +1 -1
  8. {runops-2.2.0 → runops-2.3.0}/src/runops/cli.py +48 -21
  9. {runops-2.2.0 → runops-2.3.0}/src/runops/harness.py +33 -25
  10. {runops-2.2.0 → runops-2.3.0}/src/runops/manifest.py +10 -1
  11. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/submit.py +18 -1
  12. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/tidy.py +75 -2
  13. {runops-2.2.0 → runops-2.3.0}/src/runops/project.py +41 -0
  14. {runops-2.2.0 → runops-2.3.0}/src/runops/skills/runops-check-status/SKILL.md +14 -11
  15. {runops-2.2.0 → runops-2.3.0}/src/runops/skills/runops-create-run/SKILL.md +10 -7
  16. {runops-2.2.0 → runops-2.3.0}/src/runops/skills/runops-reference/SKILL.md +7 -6
  17. {runops-2.2.0 → runops-2.3.0}/src/runops/skills/runops-tidy/SKILL.md +4 -0
  18. runops-2.3.0/tests/test_archive_series.py +98 -0
  19. {runops-2.2.0 → runops-2.3.0}/tests/test_lifecycle.py +16 -0
  20. runops-2.3.0/tests/test_project_scope.py +50 -0
  21. {runops-2.2.0 → runops-2.3.0}/tests/test_skills.py +18 -0
  22. {runops-2.2.0 → runops-2.3.0}/uv.lock +1 -1
  23. {runops-2.2.0 → runops-2.3.0}/.agents/skills/check/SKILL.md +0 -0
  24. {runops-2.2.0 → runops-2.3.0}/.agents/skills/improve-harness/SKILL.md +0 -0
  25. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/SKILL.md +0 -0
  26. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/agents/openai.yaml +0 -0
  27. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/examples/refactor-request.md +0 -0
  28. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/references/anti-patterns.md +0 -0
  29. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/references/api-compatibility.md +0 -0
  30. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/references/packaging-modernization.md +0 -0
  31. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/references/refactor-playbook.md +0 -0
  32. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/references/verification.md +0 -0
  33. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/scripts/api_surface_snapshot.py +0 -0
  34. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/scripts/import_smoke.py +0 -0
  35. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/scripts/inspect_python_package.py +0 -0
  36. {runops-2.2.0 → runops-2.3.0}/.agents/skills/python-package-refactor/scripts/refactor_quality_gate.py +0 -0
  37. {runops-2.2.0 → runops-2.3.0}/.agents/skills/release/SKILL.md +0 -0
  38. {runops-2.2.0 → runops-2.3.0}/.agents/skills/simulator-adapter/SKILL.md +0 -0
  39. {runops-2.2.0 → runops-2.3.0}/.agents/skills/spec-reviewer/SKILL.md +0 -0
  40. {runops-2.2.0 → runops-2.3.0}/.agents/skills/sync-docs/SKILL.md +0 -0
  41. {runops-2.2.0 → runops-2.3.0}/.claude/agents/spec-reviewer.md +0 -0
  42. {runops-2.2.0 → runops-2.3.0}/.claude/rules/architecture.md +0 -0
  43. {runops-2.2.0 → runops-2.3.0}/.claude/rules/dev-workflow.md +0 -0
  44. {runops-2.2.0 → runops-2.3.0}/.claude/rules/gotchas.md +0 -0
  45. {runops-2.2.0 → runops-2.3.0}/.claude/settings.json +0 -0
  46. {runops-2.2.0 → runops-2.3.0}/.claude/skills/check/SKILL.md +0 -0
  47. {runops-2.2.0 → runops-2.3.0}/.claude/skills/improve-harness/SKILL.md +0 -0
  48. {runops-2.2.0 → runops-2.3.0}/.claude/skills/python-package-refactor/SKILL.md +0 -0
  49. {runops-2.2.0 → runops-2.3.0}/.claude/skills/release/SKILL.md +0 -0
  50. {runops-2.2.0 → runops-2.3.0}/.claude/skills/simulator-adapter/SKILL.md +0 -0
  51. {runops-2.2.0 → runops-2.3.0}/.claude/skills/spec-reviewer/SKILL.md +0 -0
  52. {runops-2.2.0 → runops-2.3.0}/.claude/skills/sync-docs/SKILL.md +0 -0
  53. {runops-2.2.0 → runops-2.3.0}/.codex/README.md +0 -0
  54. {runops-2.2.0 → runops-2.3.0}/.codex/agents/spec-reviewer.toml +0 -0
  55. {runops-2.2.0 → runops-2.3.0}/.codex/config.toml +0 -0
  56. {runops-2.2.0 → runops-2.3.0}/.codex/rules/runops.rules +0 -0
  57. {runops-2.2.0 → runops-2.3.0}/.dockerignore +0 -0
  58. {runops-2.2.0 → runops-2.3.0}/.gitattributes +0 -0
  59. {runops-2.2.0 → runops-2.3.0}/.github/assets/conversation-dark.svg +0 -0
  60. {runops-2.2.0 → runops-2.3.0}/.github/assets/conversation-light.svg +0 -0
  61. {runops-2.2.0 → runops-2.3.0}/.github/assets/hero-dark.svg +0 -0
  62. {runops-2.2.0 → runops-2.3.0}/.github/assets/hero-light.svg +0 -0
  63. {runops-2.2.0 → runops-2.3.0}/.github/assets/make_readme_art.py +0 -0
  64. {runops-2.2.0 → runops-2.3.0}/.github/workflows/ci.yml +0 -0
  65. {runops-2.2.0 → runops-2.3.0}/.github/workflows/publish.yml +0 -0
  66. {runops-2.2.0 → runops-2.3.0}/.gitignore +0 -0
  67. {runops-2.2.0 → runops-2.3.0}/.harness/manifest.toml +0 -0
  68. {runops-2.2.0 → runops-2.3.0}/.pre-commit-config.yaml +0 -0
  69. {runops-2.2.0 → runops-2.3.0}/.vscode/settings.json +0 -0
  70. {runops-2.2.0 → runops-2.3.0}/AGENTS.md +0 -0
  71. {runops-2.2.0 → runops-2.3.0}/CLAUDE.md +0 -0
  72. {runops-2.2.0 → runops-2.3.0}/LICENSE +0 -0
  73. {runops-2.2.0 → runops-2.3.0}/docs/adapter.md +0 -0
  74. {runops-2.2.0 → runops-2.3.0}/src/runops/adapters/__init__.py +0 -0
  75. {runops-2.2.0 → runops-2.3.0}/src/runops/adapters/base.py +0 -0
  76. {runops-2.2.0 → runops-2.3.0}/src/runops/adapters/generic.py +0 -0
  77. {runops-2.2.0 → runops-2.3.0}/src/runops/adapters/registry.py +0 -0
  78. {runops-2.2.0 → runops-2.3.0}/src/runops/case.py +0 -0
  79. {runops-2.2.0 → runops-2.3.0}/src/runops/config.py +0 -0
  80. {runops-2.2.0 → runops-2.3.0}/src/runops/errors.py +0 -0
  81. {runops-2.2.0 → runops-2.3.0}/src/runops/jobgen.py +0 -0
  82. {runops-2.2.0 → runops-2.3.0}/src/runops/launchers.py +0 -0
  83. {runops-2.2.0 → runops-2.3.0}/src/runops/legacy_adapters/__init__.py +0 -0
  84. {runops-2.2.0 → runops-2.3.0}/src/runops/legacy_adapters/beach.py +0 -0
  85. {runops-2.2.0 → runops-2.3.0}/src/runops/legacy_adapters/emses.py +0 -0
  86. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/__init__.py +0 -0
  87. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/common.py +0 -0
  88. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/create.py +0 -0
  89. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/listing.py +0 -0
  90. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/migrate.py +0 -0
  91. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/notes.py +0 -0
  92. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/setup.py +0 -0
  93. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/skills.py +0 -0
  94. {runops-2.2.0 → runops-2.3.0}/src/runops/ops/sync.py +0 -0
  95. {runops-2.2.0 → runops-2.3.0}/src/runops/runtime.py +0 -0
  96. {runops-2.2.0 → runops-2.3.0}/src/runops/skills/runops-maintain/SKILL.md +0 -0
  97. {runops-2.2.0 → runops-2.3.0}/src/runops/slurm.py +0 -0
  98. {runops-2.2.0 → runops-2.3.0}/src/runops/util.py +0 -0
  99. {runops-2.2.0 → runops-2.3.0}/tests/__init__.py +0 -0
  100. {runops-2.2.0 → runops-2.3.0}/tests/conftest.py +0 -0
  101. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/.codex/rules/a.md +0 -0
  102. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/.runops/test-runs/T20260701-0001/receipt.toml +0 -0
  103. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/.runops/work/beach-x-abc/bin/beach +0 -0
  104. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/AGENTS.md +0 -0
  105. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/CLAUDE.md +0 -0
  106. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/cases/beach/plane/beach.toml +0 -0
  107. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/cases/beach/plane/case.toml +0 -0
  108. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/cases/old/beach.toml +0 -0
  109. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/cases/old/case.toml +0 -0
  110. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/launchers.toml +0 -0
  111. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/research/CURRENT.md +0 -0
  112. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/runops.toml +0 -0
  113. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/runs/AGENTS.md +0 -0
  114. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/runs/_archive/plane/R20260601-0001/manifest.toml +0 -0
  115. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/runs/plane/R20260701-0001--a/input/beach.toml +0 -0
  116. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/runs/plane/R20260701-0001--a/manifest.toml +0 -0
  117. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/runs/plane/R20260702-0001/manifest.toml +0 -0
  118. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/runs/scan/R20260701-0002/manifest.toml +0 -0
  119. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/runs/scan/survey.toml +0 -0
  120. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/simulators.toml +0 -0
  121. {runops-2.2.0 → runops-2.3.0}/tests/fixtures/v1-project/site.toml +0 -0
  122. {runops-2.2.0 → runops-2.3.0}/tests/golden/mpi_standard.sh +0 -0
  123. {runops-2.2.0 → runops-2.3.0}/tests/golden/openmp_rsc.sh +0 -0
  124. {runops-2.2.0 → runops-2.3.0}/tests/test_adapters.py +0 -0
  125. {runops-2.2.0 → runops-2.3.0}/tests/test_build.py +0 -0
  126. {runops-2.2.0 → runops-2.3.0}/tests/test_dev_harness.py +0 -0
  127. {runops-2.2.0 → runops-2.3.0}/tests/test_jobgen.py +0 -0
  128. {runops-2.2.0 → runops-2.3.0}/tests/test_legacy_adapters.py +0 -0
  129. {runops-2.2.0 → runops-2.3.0}/tests/test_manifest_config.py +0 -0
  130. {runops-2.2.0 → runops-2.3.0}/tests/test_migrate.py +0 -0
  131. {runops-2.2.0 → runops-2.3.0}/tests/test_mv_naming.py +0 -0
  132. {runops-2.2.0 → runops-2.3.0}/tests/test_safety.py +0 -0
  133. {runops-2.2.0 → runops-2.3.0}/tests/test_scratch_tidy.py +0 -0
  134. {runops-2.2.0 → runops-2.3.0}/tests/test_setup_misc.py +0 -0
  135. {runops-2.2.0 → runops-2.3.0}/tests/test_sweep.py +0 -0
  136. {runops-2.2.0 → runops-2.3.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.2.0
3
+ Version: 2.3.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
@@ -74,11 +74,13 @@ runo init
74
74
  | 様子を見る | 先週投げた run の状況を見て。失敗していたら原因をノートに残して |
75
75
  | 続きを流す | R20261015-0003 の続きを 2 倍の step で |
76
76
  | 片付ける | 古い run を整理して。消すものと archive するものを先に見せて |
77
+ | 作り直した旧系列を片付ける | 旧系列は新系列で置き換えたので、対応を記録して archive して |
77
78
  | simulator を足す | パッチを当てた ~/BEACH-dev をビルドして、別の simulator として登録して |
78
79
  | 更新する | runops と simulator を最新にして |
79
80
  | v1 から移る | この project を runops v2 に移行して(`runo migrate-v1` を dry-run で確認してから) |
80
81
 
81
82
  投入、削除、整理、移行の前には、エージェントが対象と資源量を示して確認を取ります。
83
+ 範囲を言わない依頼(「全部再投入して」など)は、エージェントを起動した project の run だけが対象です。
82
84
  場面ごとの流れは [使い方ガイド](https://github.com/Nkzono99/runops/blob/main/docs/guide.md) にあります。
83
85
 
84
86
  ## project の中身
@@ -43,11 +43,13 @@ runo init
43
43
  | 様子を見る | 先週投げた run の状況を見て。失敗していたら原因をノートに残して |
44
44
  | 続きを流す | R20261015-0003 の続きを 2 倍の step で |
45
45
  | 片付ける | 古い run を整理して。消すものと archive するものを先に見せて |
46
+ | 作り直した旧系列を片付ける | 旧系列は新系列で置き換えたので、対応を記録して archive して |
46
47
  | simulator を足す | パッチを当てた ~/BEACH-dev をビルドして、別の simulator として登録して |
47
48
  | 更新する | runops と simulator を最新にして |
48
49
  | v1 から移る | この project を runops v2 に移行して(`runo migrate-v1` を dry-run で確認してから) |
49
50
 
50
51
  投入、削除、整理、移行の前には、エージェントが対象と資源量を示して確認を取ります。
52
+ 範囲を言わない依頼(「全部再投入して」など)は、エージェントを起動した project の run だけが対象です。
51
53
  場面ごとの流れは [使い方ガイド](https://github.com/Nkzono99/runops/blob/main/docs/guide.md) にあります。
52
54
 
53
55
  ## project の中身
@@ -325,6 +325,7 @@ scratch = false
325
325
  expires_at = "" # scratch の場合のみ。ISO 8601
326
326
  pinned = false
327
327
  tags = ["sheath", "2d"]
328
+ superseded_by = [] # archive --superseded-by で記録した置き換え先の run id
328
329
 
329
330
  [path]
330
331
  run_dir = "runs/flat_surface/R20261015-0003--wc0147_phi45" # project root 相対。絶対 path は書かない
@@ -378,6 +379,7 @@ slurm_state = "COMPLETED"
378
379
  exit_code = 0 # exit record から
379
380
  exe_hash = "sha256:..." # submit 時点の executable の hash
380
381
  elapsed = "15:42:10" # sacct の Elapsed、無ければ exit record の開始・終了から
382
+ cancel_reason = "" # cancel --why の理由。retry はこれがあれば確認を取る
381
383
 
382
384
  [params]
383
385
  "tmgrid.nx" = 4000
@@ -461,6 +463,9 @@ created ──submit──▶ submitted ──(slurm)──▶ running ──▶
461
463
  見つからなければ exit code 2 でエラー。
462
464
  - `RUN` 引数は run id、run directory path、または directory 名の一意な部分文字列を受け付ける。
463
465
  複数一致は exit code 2 でエラーにし候補を表示する。
466
+ - path で与える `RUN` と `DIR`(`submit --all`、`list`、`archive`)は、この project の `runs/`
467
+ 配下に限る。別 project の path は exit code 2 で拒否する。run id は project ごとの空間なので、
468
+ 同じ id が別 project にあっても cwd の project の run を指す。
464
469
  - 一覧系 command は `--json` を持ち、agent が機械可読に読める。`new` と `sweep --apply` の
465
470
  `--json` は作成した run の同じ summary を返す。summary には `params`、`changed_keys`、
466
471
  `parent_run`、`exe_hash`、`input_hash` を含め、run 同士の比較に別 command を要らなくする。
@@ -494,8 +499,8 @@ created ──submit──▶ submitted ──(slurm)──▶ running ──▶
494
499
  | `runo sweep CASE --set k=v1,v2,... [--set ...] [--names KEY=n1,n2,...]... [--label L] [--dest DIR] [--why TEXT] [--apply] [--submit] [--limit N] [--json]` | 直積の候補を表示する。`--apply` で生成。`--submit` は `--apply` と同時のみ有効 |
495
500
  | `runo submit RUN... [--partition P] [--qos Q] [--afterok JOBID] [-y]` | job.sh を生成して sbatch。`created` 以外は拒否 |
496
501
  | `runo submit --all [DIR] [-y]` | DIR 配下の `created` run をすべて投入。件数と概算 core-hours を表示して確認 |
497
- | `runo cancel RUN... [-y]` | scancel して sync |
498
- | `runo retry RUN [--submit]` | `failed`/`cancelled` を `created` に戻す |
502
+ | `runo cancel RUN... [--why TEXT] [-y]` | scancel して sync。`--why` は `cancelled: ...` の note として残す |
503
+ | `runo retry RUN [--submit] [-y]` | `failed`/`cancelled` を `created` に戻す。直近の cancel に `--why` があれば理由を示して確認を取る |
499
504
  | `runo status [RUN...] [--json]` | manifest の状態を表示。Slurm を呼ばない |
500
505
  | `runo sync [RUN...] [--json]` | squeue/sacct で状態を更新。引数なしは `submitted`/`running` 全件 |
501
506
  | `runo log RUN [--err] [-n N] [-f]` | 最新 job の stdout/stderr を表示。`-f` は tail -f |
@@ -504,7 +509,7 @@ created ──submit──▶ submitted ──(slurm)──▶ running ──▶
504
509
  | `runo pin RUN [--off] [--reason TEXT]` | `run.pinned` を切り替える。pinned は `list` に常に出て、`tidy` / `delete` の対象外 |
505
510
  | `runo summarize RUN... [--force]` | adapter の `summarize()` を呼び `analysis/summary.json` を書く。既存があれば `--force` なしではスキップ |
506
511
  | `runo mv RUN... [--dest DIR] [--label L] [--why TEXT]` | run を `runs/` 配下で改名・移動する。id、入力、履歴は変えない(§7.7) |
507
- | `runo archive RUN... [-y]` / `runo archive --older-than 90d [-y]` | `runs/_archive/<元の相対 path>` に移動して `archived` にする |
512
+ | `runo archive RUN\|DIR... [--superseded-by RUN\|DIR --why TEXT] [-y]` / `runo archive --older-than 90d [-y]` | `runs/_archive/<元の相対 path>` に移動して `archived` にする。移動前に全対象を検査し、指定した RUN が終端でなければ何も動かさない。DIR は配下の run(系列)を指し、終端でない run と pinned を除いて表示する。`--superseded-by` は各 run に置き換え先(`parent_run` がその run の置き換え先 run、無ければ指定した全 run)を `run.superseded_by` に記録し、`--why` を note に残す |
508
513
  | `runo restore RUN` | archive 前の場所に戻す。既に何かあれば拒否 |
509
514
  | `runo delete RUN... [-y]` | directory を削除。`created`/`failed`/`cancelled`/scratch のみ。`pinned` は拒否 |
510
515
  | `runo tidy [--apply] [--only KIND] [--older-than AGE] [--purge-work RUN...] [--json]` | 整理候補を表示する(§7.5)。`--apply` で実行 |
@@ -740,10 +745,12 @@ seal、index を持たない)。
740
745
 
741
746
  `init` が生成する agent 向けファイルは次だけ。
742
747
 
743
- - `AGENTS.md`: 120 行以内。project 名、simulator、§7.3 の command 表、§9 のノート規約、
744
- §5 の「variation は case ではなく run で表現する」規約、
745
- 「submit 前に対象と資源量を提示して確認を取る」「`manifest.toml` と `input/` を直接
746
- 編集しない」の 2 つの安全規則、展開済み skill の一覧。
748
+ - `AGENTS.md`: 120 行以内。project 名、simulator、§7.3 の command 表、作業の進め方、
749
+ §9 のノート規約、展開済み skill の一覧。作業の進め方は次の 5 項目で、それぞれ「何をするか」と
750
+ 短い理由で書く(禁止事項の列挙にしない)。依頼が操作の範囲を決める、submit 前に対象と資源量を
751
+ 示して確認を取る、操作対象は起動した project の run と job、run の変更は `runo` を通す
752
+ (variation は case ではなく run、§5)、理由を残す(`--why`、`cancel --why`、`--superseded-by`)。
753
+ - skill は手順を書き、AGENTS.md の方針を繰り返さない。
747
754
  - harness file は project root にだけ置く。`runs/` や `cases/` 配下には生成しない。
748
755
  - `CLAUDE.md`: `@AGENTS.md` の 1 行。
749
756
  - `.agents/skills/`、`.claude/skills/`: §8.2、§8.3 の展開結果。
@@ -957,4 +964,7 @@ v2 の文書は次の 4 ファイルだけにする。
957
964
  | 整理と命名のしきい値 | `[defaults]` に `tidy_failed_days`、`tidy_archive_days`、`label_max_length` を追加。completed の目安は「`--older-than` の 3 倍」をやめて独立の値に | project ごとに計算期間や命名が違い、エージェントに毎回 option を渡させると揃わない |
958
965
  | `runo init` の対話と site の推定 | 端末から引数なしで実行したときだけ site と simulator を尋ねる。site の既定はホスト名(`camphor*`、`laurel*`、`cinnamon*`、`gardenia*` なら `kudpc`)。プリセット名は `kudpc` とし、`camphor` も同じ意味で受け付ける | 人が打つのは `runo init` だけなので、ここで KUDPC 向けの設定を取り違えないようにする。エージェントや CI からの実行(端末でない、`-y`、引数あり)は従来どおり尋ねない |
959
966
  | 異常系と同時操作(2026-10-20) | `run.lock` 下で manifest を読み直す、準備の失敗で program を起動しない、adapter を読めなければ purge しない、build cache の key を内容と `build_command` から作る、run の入れ子を禁止、run id を再利用しない(`last_run_id`)、`new --from` で case の変更を警告 | 外部レビューで、二重投入で 1 回目の履歴が消える、準備失敗が completed になる、adapter の故障で保持ファイルが消える、未追跡ファイルの変更で古い binary を使う、削除した id が別の run を指す、を確認した |
967
+ | 操作範囲は起動 project(2026-10-21) | AGENTS.md block と skill に「対象は起動した project の run と job id だけ。アカウント全体の squeue / sacct は診断に見るだけ。自分で別 directory に移らず、生の `sbatch` / `scancel` を使わない」を書く。CLI は別 project の path を拒否する | 範囲を言わない「再投入して」で、エージェントが別 project や runops 管理外のジョブまで再投入した。cwd で project を決める既存の model に、path 引数の抜け道(`submit --all /other/runs` など)があった。他 session が修正のために止めた run を見分けられるよう、`cancel --why` を note に残し、`retry` はその理由を示して確認を取る(#112) |
968
+ | 置き換えた系列の整理(2026-10-21) | `archive` が DIR(系列)を受け、`--superseded-by` で置き換え先を manifest に記録する。新しい command と tidy の種別は足さない。skill は新系列を作ったら旧系列の要否を確かめ、不要なら archive して現用系列・archive 先・件数を報告する | 修正版を作った後に旧系列が作業領域に残り、毎回ユーザーが整理を頼んでいた。`parent_run` だけでは比較用の派生と置き換えを区別できないので、置き換えは明示操作で記録する。旧 `archive` は 1 件ずつ検査していて、途中の running run で一部だけ移動していた(#113) |
969
+ | 生成ハーネスの書き方(2026-10-21) | AGENTS.md の Rules を「作業の進め方」5 項目に絞り、各項目を「何をするか」と短い理由で書く。skill は手順に専念し、AGENTS.md の方針(manifest を手で編集しない等)を繰り返さない | #112・#113 で規則が増え、禁止形(never / do not)が並んでいた。現行の Claude の prompting 指針は、しないことより何をするかを、理由と共に書くことを勧め、強い禁止語は過剰反応を招くとしている |
960
970
  | 使い方ガイド | `docs/guide.md` を追加し、文書を 4 ファイルにする(§13.3) | README の依頼例の表と command 一覧の間を埋める、場面ごとの流れ(case と run の分け方、smoke から本計算、状態の決まり方、整理)の置き場所が無かった |
@@ -160,11 +160,18 @@ runops が知らない Slurm 状態の job は状態を変えず、`last_slurm_s
160
160
  | 原因を調べる | R20261016-0003 が落ちた理由を調べて | `runo log RUN --err`、`runo summarize RUN`、`runo note RUN "..."` |
161
161
  | 一時的な障害で、同じ入力で流し直す | 同じ入力でもう一度投げて | `runo retry RUN`、`runo submit RUN` |
162
162
  | 入力を直して流し直す | dt を半分にして作り直して | `runo new --from RUN --set ... --why "..."` |
163
- | 止める | R20261016-0004 を止めて | `runo cancel RUN` |
163
+ | 止める | R20261016-0004 を止めて | `runo cancel RUN`(直すために止めるときは `--why`) |
164
164
 
165
165
  `retry` は `failed` / `cancelled` の run を `created` に戻し、試行回数を 1 つ増やします。
166
166
  `work/` は消しません。
167
167
 
168
+ 「消えたジョブを再投入して」「全部止めて」のように範囲を言わない依頼は、エージェントを起動した
169
+ project の run だけが対象です。同じアカウントの別 project のジョブや runops 外のジョブは、
170
+ `squeue` に見えていても対象にせず、該当する run が無ければそう伝えます。別 project の run も
171
+ 扱いたいときは、その project か run を名指ししてください。
172
+ 入力を直すために止めた run は `runo cancel RUN --why "..."` で理由を残すと、別のセッションが
173
+ 「消えたジョブ」として再投入しません(`retry` が理由を示して確認を取ります)。
174
+
168
175
  ## 5. 続きを流す
169
176
 
170
177
  > R20261015-0003 の続きを 2 倍の step で。
@@ -211,6 +218,18 @@ run は消さなくても `runo list` が古いものを隠しますが、direct
211
218
  候補を確認したら `runo tidy --apply` で実行します。archive は `runs/_archive/` への移動で、
212
219
  run id で引き続き見つかり、`runo restore` で戻せます。
213
220
 
221
+ ### 置き換えた系列を片付ける
222
+
223
+ 不備を直して新しい系列を作り直すと、エージェントは旧系列がまだ要るかを尋ねます。要らなければ
224
+
225
+ > 旧系列は不要。新系列で置き換えたと記録して archive して。
226
+
227
+ で `runo archive runs/<case>/<旧系列> --superseded-by runs/<case>/<新系列> --why "..."` になります。
228
+ 系列の directory を渡すと、対象の run を先に表示し、投入中・実行中・作成のみの run と pin した run は
229
+ 除きます。各旧 run の `superseded_by` には、その run から `--from` で作った新 run(無ければ新系列の全 run)が
230
+ 記録され、理由はノートに残ります。比較用に派生しただけの run は置き換えとは扱わないので、
231
+ 旧系列が不要かどうかは毎回確かめます。
232
+
214
233
  論文の図に使う run や基準にする run は pin しておくと、`list` に常に表示され、`tidy` や `delete`
215
234
  の対象になりません。
216
235
 
@@ -6,6 +6,9 @@
6
6
  ## runops.toml
7
7
 
8
8
  project root にあり、`runo` はここを上方向に探して project を特定します。
9
+ command はこの project の `runs/` 配下だけを扱い、別 project の run directory や
10
+ directory を path で渡すとエラーになります。run id は project ごとなので、同じ id が
11
+ 別 project にあっても cwd の project の run を指します。
9
12
 
10
13
  ```toml
11
14
  schema = 2
@@ -103,11 +106,11 @@ runops だけが書きます。手で編集しないでください。主な fie
103
106
 
104
107
  | section | field |
105
108
  |---|---|
106
- | `[run]` | `id`、`label`、`status`、`created_at`、`why`、`scratch`、`expires_at`、`pinned`、`tags`、`status_before_archive` |
109
+ | `[run]` | `id`、`label`、`status`、`created_at`、`why`、`scratch`、`expires_at`、`pinned`、`tags`、`status_before_archive`、`superseded_by`(置き換え先の run id) |
107
110
  | `[path]` | `run_dir`(project 相対)、`archived_from`、`archived_at` |
108
111
  | `[origin]` | `case`、`sweep`、`parent_run`、`created_by`、`case_input_hash` |
109
112
  | `[simulator]` | `name`、`adapter`、`adapter_source`、`adapter_version`、`executable`、`resolver_mode`、`package_version`、`exe_hash`、`git_commit`、`git_dirty`、`build_dir` |
110
- | `[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`) |
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`) |
111
114
  | `[params]` | case の params に `--set` を適用した最終値 |
112
115
  | `[variation]` | `changed_keys` |
113
116
  | `[identity]` | `input_hash` |
@@ -154,13 +157,13 @@ run id は削除後も再利用しません。`--dest` に既存 run の中は
154
157
  | `runo new --from RUN [--set k=v]... [--continue]` | 既存 run の params から派生。`--continue` は adapter の restart 対応が必要 |
155
158
  | `runo sweep CASE --set k=v1,v2 ... [--names k=n1,n2...] [--label] [--dest DIR] [--limit N] [--apply] [--submit] [--json]` | 直積の候補表示。`--apply` で作成。`--names` は各値の短い label、`--dest` は sweep directory の親 |
156
159
  | `runo submit RUN... [-p P] [--qos Q] [--afterok JOB]` / `runo submit --all [DIR] [-y]` | job.sh を生成して sbatch |
157
- | `runo cancel RUN... [-y]` / `runo retry RUN [--submit]` | scancel / failed・cancelled を created に戻す |
160
+ | `runo cancel RUN... [--why TEXT] [-y]` / `runo retry RUN [--submit] [-y]` | scancel(`--why` は note に残る)/ failed・cancelled を created に戻す(理由付きで cancel された run は確認を取る) |
158
161
  | `runo status [RUN...]` / `runo sync [RUN...]` / `runo log RUN [--err] [-n N] [-f]` | 状態表示 / Slurm から更新 / log 表示 |
159
162
  | `runo list [PATH] [--all] [--since 30d] [--case] [--sweep] [--status] [--tag] [--scratch] [--archived] [--json]` | run 一覧 |
160
163
  | `runo note RUN [TEXT]` / `runo pin RUN [--off] [--reason]` | ノート追記・表示 / pin |
161
164
  | `runo summarize RUN... [--force]` | adapter の要約を `analysis/summary.json` に書く |
162
165
  | `runo mv RUN... [--dest DIR] [--label L] [--why TEXT]` | `runs/` 配下で改名・移動。id、入力、sweep、ノートは保つ。submitted / running と archived は不可 |
163
- | `runo archive RUN... ` / `runo archive --older-than 90d` / `runo restore RUN` | `runs/_archive/` へ移動 / 戻す |
166
+ | `runo archive RUN\|DIR... [--superseded-by RUN\|DIR --why TEXT]` / `runo archive --older-than 90d` / `runo restore RUN` | `runs/_archive/` へ移動 / 戻す。移動前に全対象を検査し、指定した RUN が終了していなければ何も動かさない。DIR は配下の run(系列)で、created / submitted / running と pinned を除いて表示する。`--superseded-by` は置き換え先(その run から `--from` で派生した run、無ければ指定した全 run)を `superseded_by` に記録し、`--why` をノートに残す |
164
167
  | `runo delete RUN... [-y]` | created・failed・cancelled・scratch の run を削除 |
165
168
  | `runo tidy [--apply] [--only KIND] [--older-than AGE] [--purge-work RUN...]` | 整理候補の表示と実行 |
166
169
  | `runo migrate-v1 [--dry-run] [--apply-delete-test-runs] [-y]` | v1 project の変換 |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runops"
7
- version = "2.2.0"
7
+ version = "2.3.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.2.0"
3
+ __version__ = "2.3.0"
@@ -488,7 +488,7 @@ def submit_cmd(
488
488
  if all_runs:
489
489
  if runs and len(runs) > 1:
490
490
  raise UsageError("--all takes at most one DIR")
491
- under = Path(runs[0]).resolve() if runs else project.runs_dir
491
+ under = project.runs_subdir(runs[0]) if runs else project.runs_dir
492
492
  targets = [
493
493
  m
494
494
  for m in project.iter_runs(under)
@@ -524,6 +524,9 @@ def submit_cmd(
524
524
  @app.command()
525
525
  def cancel(
526
526
  runs: list[str] = typer.Argument(...),
527
+ why: str = typer.Option(
528
+ "", "--why", help="Reason, recorded as a note (e.g. 'wrong input; replaced by ...')."
529
+ ),
527
530
  yes: bool = typer.Option(False, "-y", "--yes"),
528
531
  ) -> None:
529
532
  """scancel submitted/running runs."""
@@ -533,7 +536,7 @@ def cancel(
533
536
  targets = _resolve(project, runs)
534
537
  _confirm("Cancel " + ", ".join(f"{m.id} (job {m.job_id})" for m in targets) + "?", yes)
535
538
  for m in targets:
536
- cancel_run(project, m, runner=_runner())
539
+ cancel_run(project, m, runner=_runner(), why=why, by=_actor())
537
540
  out.print(f"cancelled {m.id}")
538
541
 
539
542
 
@@ -541,12 +544,17 @@ def cancel(
541
544
  def retry(
542
545
  run: str = typer.Argument(...),
543
546
  submit: bool = typer.Option(False, "--submit"),
547
+ yes: bool = typer.Option(False, "-y", "--yes"),
544
548
  ) -> None:
545
549
  """Reset a failed/cancelled run to created (work/ is kept)."""
546
- from runops.ops.submit import retry_run
550
+ from runops.ops.submit import cancel_reason, retry_run
547
551
 
548
552
  project = _project()
549
553
  m = project.resolve_run(run)
554
+ reason = cancel_reason(m)
555
+ if reason:
556
+ # Someone cancelled it on purpose (e.g. to fix and replace it); do not revive it silently.
557
+ _confirm(f"{m.id} was cancelled: {reason}. Retry anyway?", yes)
550
558
  retry_run(project, m)
551
559
  out.print(f"{m.id} -> created")
552
560
  if submit:
@@ -687,7 +695,7 @@ def list_cmd(
687
695
  if s not in {state.value for state in RunState}:
688
696
  raise UsageError(f"unknown status {s!r}")
689
697
  flt = ListFilter(
690
- path=path.resolve() if path else None,
698
+ path=project.runs_subdir(path) if path else None,
691
699
  show_all=show_all,
692
700
  since=parse_age(since) if since else None,
693
701
  cases=cases or [],
@@ -771,39 +779,58 @@ def summarize(
771
779
 
772
780
  @app.command()
773
781
  def archive(
774
- runs: Optional[list[str]] = typer.Argument(None),
782
+ runs: Optional[list[str]] = typer.Argument(
783
+ None, help="Runs, or directories under runs/ (a series)."
784
+ ),
775
785
  older_than: str = typer.Option(
776
786
  "", "--older-than", help="Archive completed runs older than this (e.g. 90d)."
777
787
  ),
788
+ superseded_by: str = typer.Option(
789
+ "",
790
+ "--superseded-by",
791
+ help="Run or series directory that replaces these runs; recorded in each manifest.",
792
+ ),
793
+ why: str = typer.Option("", "--why", help="Reason, recorded in each run's notes."),
778
794
  yes: bool = typer.Option(False, "-y", "--yes"),
779
795
  ) -> None:
780
796
  """Move terminal runs to runs/_archive/ (same relative path)."""
781
797
  from datetime import datetime
782
798
 
783
- from runops.ops.tidy import archive_run
799
+ from runops.ops.tidy import ArchivePlan, archive_run, plan_archive
784
800
 
785
801
  project = _project()
802
+ if superseded_by and not why:
803
+ raise UsageError("--superseded-by needs --why")
786
804
  if older_than:
787
- if runs:
805
+ if runs or superseded_by:
788
806
  raise UsageError("give RUNs or --older-than, not both")
789
807
  cutoff = datetime.now().astimezone() - parse_age(older_than)
790
- targets = [
791
- m
792
- for m in project.iter_runs()
793
- if m.status == RunState.COMPLETED
794
- and not m.pinned
795
- and (m.created_datetime() or cutoff) < cutoff
796
- ]
808
+ plan = ArchivePlan(
809
+ targets=[
810
+ m
811
+ for m in project.iter_runs()
812
+ if m.status == RunState.COMPLETED
813
+ and not m.pinned
814
+ and (m.created_datetime() or cutoff) < cutoff
815
+ ]
816
+ )
797
817
  else:
798
- targets = _resolve(project, runs or [])
799
- if not targets:
818
+ plan = plan_archive(project, runs or [], superseded_by=superseded_by)
819
+ for m, reason in plan.skipped:
820
+ out.print(f" skip {m.id} {reason} {project.rel(m.run_dir)}")
821
+ if not plan.targets:
800
822
  out.print("nothing to archive")
801
823
  return
802
- for m in targets:
803
- out.print(f" {m.id} {project.rel(m.run_dir)}")
804
- _confirm(f"Archive {len(targets)} runs?", yes)
805
- for m in targets:
806
- dst = archive_run(project, m)
824
+ for m in plan.targets:
825
+ line = f" {m.id} {m.status.value} {project.rel(m.run_dir)}"
826
+ if m.id in plan.superseded_by:
827
+ line += f" -> {', '.join(plan.superseded_by[m.id])}"
828
+ out.print(line)
829
+ _confirm(f"Archive {len(plan.targets)} runs?", yes)
830
+ for m in plan.targets:
831
+ dst = archive_run(
832
+ project, m, superseded_by=plan.superseded_by.get(m.id), why=why, by=_actor()
833
+ )
807
834
  out.print(f"archived {m.id} -> {project.rel(dst)}")
808
835
 
809
836
 
@@ -20,12 +20,13 @@ COMMANDS = """\
20
20
  | `runo submit RUN... [--partition P]` / `runo submit --all DIR` | sbatch created runs |
21
21
  | `runo sync` / `runo status RUN` / `runo log RUN [--err]` | refresh state from Slurm / show / read logs |
22
22
  | `runo list [--all] [--case C] [--status S] [--json]` | recent + active runs (old, scratch, archived hidden) |
23
- | `runo cancel RUN` / `runo retry RUN` | stop a job / reset failed or cancelled to created |
23
+ | `runo cancel RUN [--why T]` / `runo retry RUN` | stop a job / reset failed or cancelled to created |
24
24
  | `runo note RUN "text"` / `runo pin RUN --reason TEXT` | append an observation / protect a key run |
25
25
  | `runo summarize RUN` | adapter summary into analysis/summary.json |
26
26
  | `runo tidy` / `runo tidy --apply` | list / apply cleanup (expired scratch, stale failures, old runs) |
27
27
  | `runo mv RUN... [--dest DIR] [--label L] --why TEXT` | rename / move runs, keeping ids and inputs |
28
- | `runo archive RUN` / `runo restore RUN` / `runo delete RUN` | move to runs/_archive / back / remove |
28
+ | `runo archive RUN\\|DIR... [--superseded-by RUN\\|DIR --why T]` | move finished runs (or a series) to runs/_archive |
29
+ | `runo restore RUN` / `runo delete RUN` | move back / remove |
29
30
  | `runo build SIM` | build a local_source simulator into .runops/build |
30
31
  | `runo doctor` / `runo update` | check environment / upgrade simulator packages and skills |"""
31
32
 
@@ -42,8 +43,9 @@ def render_block(project: Project, skills: list[str]) -> str:
42
43
  return f"""{BEGIN}
43
44
  # {project.config.name}
44
45
 
45
- This is a runops project: simulation runs are tracked as directories under
46
- `runs/`, each with a `manifest.toml` owned by runops. Use the `runo` CLI.
46
+ This is a runops project. Each run is a directory under `runs/` with a `manifest.toml`
47
+ that runops owns. The `runo` CLI creates, submits, tracks and tidies runs, and acts on
48
+ the project found from the current directory.
47
49
 
48
50
  ## Simulators
49
51
 
@@ -53,30 +55,36 @@ This is a runops project: simulation runs are tracked as directories under
53
55
 
54
56
  {COMMANDS}
55
57
 
56
- ## Rules
57
-
58
- - The request sets what may happen: "make/create" creates runs only; "run/submit" also
59
- submits; "try/test" allows a short `--scratch` run and its submission, never a
60
- production submit. Earlier runs may fill in parameters, not add actions.
61
- - Before `submit`, `sweep --apply --submit`, or `submit --all`, show the target runs,
62
- partition and resources, and get confirmation.
63
- - After a production submit is accepted, report the job ids and stop. When asked to
64
- watch, check once for early failure, then near the expected end; no `tail -f` loops.
65
- - Never edit `manifest.toml` or a run's `input/` by hand. Change the case or create a
66
- new run with `--set` / `new --from`. To rename or move a run, use `runo mv`, not a
67
- re-created copy.
68
- - Express variations as runs, not cases: use `runo new CASE --set k=v`,
69
- `runo new --from RUN --set k=v`, or `runo sweep`. Create a new case only when the
70
- structure of the base input files changes.
71
- - Always pass `--why` when creating runs. Use `--scratch` for smoke/debug runs.
58
+ ## How to work here
59
+
60
+ - **Let the request set the actions.** "make/create" means creating runs; "run/submit"
61
+ also submits; "try/test" means a short `--scratch` run that you may submit. Earlier
62
+ runs can supply parameters, while the request decides what happens.
63
+ - **Confirm before compute is spent.** Before any submit, show the runs, partition,
64
+ resources and core-hours, and wait for the go-ahead. Once Slurm accepts the jobs,
65
+ report the job ids and end the turn; Slurm does the waiting. When asked to watch,
66
+ check once early and once near the expected end.
67
+ - **Work within this project.** Requests such as "resubmit everything" mean the runs
68
+ under `runs/` here: find them with `runo sync` and `runo list`. Account-wide `squeue`
69
+ and `sacct` help diagnose, but other projects and unmanaged jobs belong to other
70
+ sessions, so act on them only when the user names them. If nothing here matches, say so.
71
+ - **Change runs through `runo`.** `runo` writes each run's manifest and `input/`, so
72
+ they stay an exact record of what ran.
73
+ Express a change as a new run (`runo new --from RUN --set k=v`, `runo sweep`) and
74
+ rename or regroup with `runo mv`. Variations are runs; a new case is for a different
75
+ structure of the base input.
76
+ - **Leave the reason behind.** Give each new run `--why`. When you cancel runs to fix or
77
+ replace them, add `runo cancel --why`, so later sessions and `runo retry` leave them
78
+ stopped. When a new series replaces an old one, ask whether the old one is still
79
+ needed and archive it with `--superseded-by`.
72
80
 
73
81
  ## Notes
74
82
 
75
- - Observations about one run: `runo note RUN "..."` (or the run's `notes.md`).
76
- - Conclusions across runs and next decisions: append to `LOG.md` under a dated heading.
77
- - Reusable knowledge (formulas, methods, pitfalls): `notes/<topic>.md`, one file per
78
- topic. Extend an existing file instead of creating a similar one.
79
- - Do not create note files anywhere else (`research/`, `analysis/*.md`, `runs/`).
83
+ Notes live in three places, where `runo context` and the next session find them:
84
+ - one run: `runo note RUN "..."`;
85
+ - conclusions across runs and next decisions: `LOG.md`, appended under a dated heading;
86
+ - reusable knowledge (formulas, methods, pitfalls): `notes/<topic>.md`, extending the
87
+ existing file for a topic.
80
88
 
81
89
  ## Skills
82
90
 
@@ -133,6 +133,14 @@ class Manifest:
133
133
  def sweep(self) -> str:
134
134
  return str(self.section("origin").get("sweep", ""))
135
135
 
136
+ @property
137
+ def parent_run(self) -> str:
138
+ return str(self.section("origin").get("parent_run", ""))
139
+
140
+ @property
141
+ def superseded_by(self) -> list[str]:
142
+ return [str(r) for r in self.run.get("superseded_by", [])]
143
+
136
144
  @property
137
145
  def params(self) -> dict[str, Any]:
138
146
  return dict(self.section("params"))
@@ -190,7 +198,8 @@ class Manifest:
190
198
  # Enough for an agent to compare runs without another command.
191
199
  "params": self.params,
192
200
  "changed_keys": list(self.section("variation").get("changed_keys", [])),
193
- "parent_run": str(self.section("origin").get("parent_run", "")),
201
+ "parent_run": self.parent_run,
202
+ "superseded_by": self.superseded_by,
194
203
  "exe_hash": str(self.section("simulator").get("exe_hash", "")),
195
204
  "input_hash": str(self.section("identity").get("input_hash", "")),
196
205
  # Walltime evidence for the next run: elapsed of the latest attempt.
@@ -172,7 +172,12 @@ def _current_exe_hash(manifest: Manifest) -> str:
172
172
 
173
173
 
174
174
  def cancel_run(
175
- project: Project, manifest: Manifest, *, runner: CommandRunner = default_runner
175
+ project: Project,
176
+ manifest: Manifest,
177
+ *,
178
+ runner: CommandRunner = default_runner,
179
+ why: str = "",
180
+ by: str = "",
176
181
  ) -> None:
177
182
  with project.run_lock():
178
183
  manifest.reload()
@@ -187,10 +192,22 @@ def cancel_run(
187
192
  if attempts:
188
193
  attempts[-1]["finished_at"] = now
189
194
  attempts[-1]["slurm_state"] = "CANCELLED"
195
+ if why:
196
+ (attempts[-1] if attempts else manifest.job)["cancel_reason"] = why
197
+ manifest.add_note(f"cancelled: {why}", by or "runops")
190
198
  manifest.set_status(RunState.CANCELLED)
191
199
  manifest.save()
192
200
 
193
201
 
202
+ def cancel_reason(manifest: Manifest) -> str:
203
+ """The ``cancel --why`` reason of a cancelled run, if its latest cancel gave one."""
204
+ if manifest.status != RunState.CANCELLED:
205
+ return ""
206
+ attempts = manifest.job.get("attempts") or []
207
+ record = attempts[-1] if attempts else manifest.job
208
+ return str(record.get("cancel_reason", ""))
209
+
210
+
194
211
  def retry_run(project: Project, manifest: Manifest) -> None:
195
212
  with project.run_lock():
196
213
  manifest.reload()
@@ -144,9 +144,76 @@ def apply_candidates(
144
144
  # ---- archive / restore / delete --------------------------------------
145
145
 
146
146
 
147
- def archive_run(project: Project, manifest: Manifest) -> Path:
147
+ _TERMINAL = (RunState.COMPLETED, RunState.FAILED, RunState.CANCELLED)
148
+
149
+
150
+ @dataclass
151
+ class ArchivePlan:
152
+ targets: list[Manifest] = field(default_factory=list)
153
+ skipped: list[tuple[Manifest, str]] = field(default_factory=list)
154
+ superseded_by: dict[str, list[str]] = field(default_factory=dict) # old id -> new ids
155
+
156
+
157
+ def plan_archive(project: Project, refs: list[str], *, superseded_by: str = "") -> ArchivePlan:
158
+ """Check every target before anything moves.
159
+
160
+ A RUN that cannot be archived is an error. A directory (a series) stands for the
161
+ runs below it; its submitted/running/created and pinned runs are skipped and listed.
162
+ With *superseded_by* (a RUN or a series directory), each old run records the
163
+ replacement runs derived from it (``origin.parent_run``), or all of them when none is.
164
+ """
165
+ if not refs:
166
+ raise UsageError("give at least one RUN or directory")
167
+ explicit: dict[str, Manifest] = {}
168
+ expanded: dict[str, Manifest] = {}
169
+ for ref in refs:
170
+ runs, is_dir = project.resolve_runs(ref)
171
+ for m in runs:
172
+ (expanded if is_dir else explicit)[m.id] = m
173
+ plan = ArchivePlan()
174
+ for run_id, m in {**expanded, **explicit}.items():
175
+ if inside(m.run_dir, project.runs_dir / ARCHIVE_DIR):
176
+ reason = "already archived"
177
+ elif m.status not in _TERMINAL:
178
+ reason = m.status.value
179
+ elif m.pinned and run_id not in explicit:
180
+ reason = "pinned"
181
+ else:
182
+ plan.targets.append(m)
183
+ continue
184
+ if run_id in explicit:
185
+ raise UsageError(f"{run_id} is {reason}; only terminal runs can be archived")
186
+ plan.skipped.append((m, reason))
187
+ if superseded_by:
188
+ new, _ = project.resolve_runs(superseded_by)
189
+ old_ids = {m.id for m in plan.targets} | {m.id for m, _ in plan.skipped}
190
+ if old_ids & {m.id for m in new}:
191
+ raise UsageError("--superseded-by must not include the runs being archived")
192
+ for m in plan.targets:
193
+ derived = [r.id for r in new if r.parent_run == m.id]
194
+ plan.superseded_by[m.id] = derived or [r.id for r in new]
195
+ return plan
196
+
197
+
198
+ def archive_run(
199
+ project: Project,
200
+ manifest: Manifest,
201
+ *,
202
+ superseded_by: list[str] | None = None,
203
+ why: str = "",
204
+ by: str = "",
205
+ ) -> Path:
206
+ """Move a terminal run to ``runs/_archive/``; record replacements and *why* if given."""
207
+ with project.run_lock():
208
+ manifest.reload()
209
+ return _archive_locked(project, manifest, superseded_by or [], why, by)
210
+
211
+
212
+ def _archive_locked(
213
+ project: Project, manifest: Manifest, superseded_by: list[str], why: str, by: str
214
+ ) -> Path:
148
215
  status = manifest.status
149
- if status not in (RunState.COMPLETED, RunState.FAILED, RunState.CANCELLED):
216
+ if status not in _TERMINAL:
150
217
  raise UsageError(f"{manifest.id} is {status.value}; only terminal runs can be archived")
151
218
  src = manifest.run_dir.resolve()
152
219
  archive_root = project.runs_dir / ARCHIVE_DIR
@@ -170,6 +237,12 @@ def archive_run(project: Project, manifest: Manifest) -> Path:
170
237
  "archived_at": now_iso(),
171
238
  }
172
239
  )
240
+ if superseded_by:
241
+ moved.run["superseded_by"] = list(superseded_by)
242
+ text = f"superseded by {', '.join(superseded_by)}"
243
+ moved.add_note(f"{text}: {why}" if why else text, by or "runops")
244
+ elif why:
245
+ moved.add_note(f"archived: {why}", by or "runops")
173
246
  moved.set_status(RunState.ARCHIVED)
174
247
  moved.save()
175
248
  project.update_index(moved.id, dst)