tdd-cli 0.13.0__tar.gz → 0.14.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 (114) hide show
  1. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/CHANGELOG.md +13 -0
  2. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/PKG-INFO +22 -1
  3. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/README.md +21 -0
  4. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/docs/PRD.md +1 -0
  5. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/__init__.py +1 -1
  6. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/cli.py +76 -5
  7. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/ledger.py +10 -0
  8. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/render.py +10 -0
  9. tdd_cli-0.14.0/tests/test_run_abandon.py +256 -0
  10. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/.gitignore +0 -0
  11. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/LICENSE +0 -0
  12. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/SECURITY.md +0 -0
  13. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/docs/harness-integration.md +0 -0
  14. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/docs/split-runner.md +0 -0
  15. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/claude-code-hooks/README.md +0 -0
  16. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/claude-code-hooks/bash_hook.py +0 -0
  17. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/claude-code-hooks/stop_hook.py +0 -0
  18. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/plan.md +0 -0
  19. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/skills/tdd-drive/README.md +0 -0
  20. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/skills/tdd-drive/SKILL.md +0 -0
  21. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/skills/tdd-handoff/README.md +0 -0
  22. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
  23. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/pyproject.toml +0 -0
  24. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/actor.py +0 -0
  25. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/__init__.py +0 -0
  26. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/base.py +0 -0
  27. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/cargo_adapter.py +0 -0
  28. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/exec_adapter.py +0 -0
  29. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/gradle_adapter.py +0 -0
  30. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
  31. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
  32. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/xctest_adapter.py +0 -0
  33. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/advance.py +0 -0
  34. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/agentfs.py +0 -0
  35. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/config.py +0 -0
  36. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/contract.py +0 -0
  37. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/docs.py +0 -0
  38. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/envelope.py +0 -0
  39. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/fleet.py +0 -0
  40. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/gitutil.py +0 -0
  41. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/identity.py +0 -0
  42. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/leases.py +0 -0
  43. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/machine.py +0 -0
  44. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/plan_paths.py +0 -0
  45. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/runner.py +0 -0
  46. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/snapshot.py +0 -0
  47. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/staging.py +0 -0
  48. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/target_lint.py +0 -0
  49. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/conftest.py +0 -0
  50. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_actor_seams.py +0 -0
  51. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_advance_adoption.py +0 -0
  52. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_ancillary_files.py +0 -0
  53. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_artifact_regeneration.py +0 -0
  54. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_baseline_integrity.py +0 -0
  55. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_baseline_sanity.py +0 -0
  56. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_batch_collection.py +0 -0
  57. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_cargo_adapter.py +0 -0
  58. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_close_sweep_gates.py +0 -0
  59. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_concurrent_advance.py +0 -0
  60. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_config_and_staging.py +0 -0
  61. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_config_drift.py +0 -0
  62. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_contract.py +0 -0
  63. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_docs_command.py +0 -0
  64. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_doctor_attribution.py +0 -0
  65. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_doctor_blockers.py +0 -0
  66. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_end_to_end.py +0 -0
  67. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_evidence_extraction.py +0 -0
  68. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_example_plan.py +0 -0
  69. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_exec_adapter.py +0 -0
  70. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_executor_attribution.py +0 -0
  71. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_executor_notes.py +0 -0
  72. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_failure_clipping.py +0 -0
  73. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_fleet.py +0 -0
  74. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_gradle_adapter.py +0 -0
  75. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_heartbeat.py +0 -0
  76. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_id_normalisation.py +0 -0
  77. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_init_detection.py +0 -0
  78. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_late_baseline.py +0 -0
  79. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_named_leases_and_timeout.py +0 -0
  80. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_pin_cycles.py +0 -0
  81. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_plan_paths.py +0 -0
  82. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_progress.py +0 -0
  83. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_project_commands.py +0 -0
  84. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_project_env.py +0 -0
  85. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_pytest_xdist_group.py +0 -0
  86. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_python_env_managers.py +0 -0
  87. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_refactor_cycles.py +0 -0
  88. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_release_surface.py +0 -0
  89. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_run_claim.py +0 -0
  90. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_runner_import.py +0 -0
  91. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_sensitivity_evidence.py +0 -0
  92. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_single_project_repo.py +0 -0
  93. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_snapshot_and_identity.py +0 -0
  94. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_client.py +0 -0
  95. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_config.py +0 -0
  96. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_doctor.py +0 -0
  97. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_identity.py +0 -0
  98. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_ledger.py +0 -0
  99. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_runner.py +0 -0
  100. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_stub_hint.py +0 -0
  101. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_sudo_actor.py +0 -0
  102. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_suite_overrides.py +0 -0
  103. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_suite_scope.py +0 -0
  104. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_target_lint.py +0 -0
  105. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_target_only_runs.py +0 -0
  106. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_target_validation.py +0 -0
  107. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_time_report.py +0 -0
  108. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_timing_visibility.py +0 -0
  109. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_tree_hash.py +0 -0
  110. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_undeclared_close_gate.py +0 -0
  111. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_undeclared_dedup.py +0 -0
  112. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_vitest_adapter.py +0 -0
  113. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_worker_leases.py +0 -0
  114. {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_xctest_adapter.py +0 -0
@@ -6,6 +6,19 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.14.0] - 2026-09-24
10
+
11
+ ### Added
12
+
13
+ - **`tdd run abandon`: end a run that will not be finished** (#150). `tdd run abandon
14
+ --reason <text>` ends the worktree's live or blocked run with `outcome = abandoned`;
15
+ `--run <id>` does the same for a run whose worktree is gone. It records the reason and
16
+ who did it (account and executor) in a new `abandonment` table, counts as a human
17
+ intervention, and releases the worktree's claims. `tdd fleet` stops listing the run,
18
+ `tdd metrics` reports it with an `abandoned: {reason, by}` entry, and a new run can
19
+ start at the same path. The table is added on open; the ledger schema version is
20
+ unchanged.
21
+
9
22
  ## [0.13.0] - 2026-09-23
10
23
 
11
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: Ledger-backed TDD process controller for autonomous coding agents
5
5
  Project-URL: Homepage, https://github.com/geuben/tdd-cli
6
6
  Project-URL: Repository, https://github.com/geuben/tdd-cli
@@ -296,6 +296,26 @@ When the last declared cycle closes, the tool gathers every path that appeared i
296
296
  emitted (visible in `tdd metrics` and the friction log) and the run completes. The event
297
297
  makes a silent drop visible without blocking, since the file is already gone.
298
298
 
299
+ ### Abandoning a run
300
+
301
+ A run that will not be finished — stopped on purpose, or blocked for good — is ended with
302
+ `tdd run abandon --reason "<why>"` rather than `resume --unblock`. It takes the worktree's
303
+ live run, else its most recent blocked run. It sets the run's `outcome` to `abandoned`,
304
+ records the reason and who abandoned it (the account, and the executor identity as `run
305
+ start` resolves it; on a split runner the account is the calling agent's `SUDO_USER`),
306
+ records a `human_intervention`, and releases the worktree's baseline and advance claims.
307
+ `tdd fleet` stops listing the run, `tdd metrics` reports it as `abandoned` with an
308
+ `abandoned: {reason, by}` entry, and a new run can start at the same worktree path.
309
+
310
+ When the worktree is gone, abandon by id from any worktree of the repo: `tdd run abandon
311
+ --run <id> --reason "<why>"`. `--run` is accepted only for a run whose worktree no longer
312
+ exists or is the caller's own, so one agent cannot end another's live run.
313
+
314
+ Refusals, under `result.reason`, write nothing: `reason_required` (blank reason),
315
+ `no_run` (no live or blocked run here), `run_not_found`, `run_ended` (already complete or
316
+ abandoned), `worktree_exists` (`--run` names another worktree that still exists), and
317
+ `advance_in_flight` (a live `tdd advance` holds the worktree; a stale claim is released).
318
+
299
319
  The gate composes with `ancillary_files`: declared paths never fire `undeclared_file_touched`,
300
320
  so they are never seen by the gate.
301
321
 
@@ -495,6 +515,7 @@ and is never reclassified as a pin.
495
515
  | `tdd note "<text>"` | attach a free-text narrative note to the current cycle or run |
496
516
  | `tdd blocker --kind --detail` | typed blocker; releases the stop hook |
497
517
  | `tdd resume [--unblock --note]` | reconstruct position; human intervention |
518
+ | `tdd run abandon --reason <text> [--run <id>]` | end a run that will not be finished; human only |
498
519
  | `tdd log render [--out]` | project the ledger into a friction log |
499
520
  | `tdd metrics` | fidelity, attempts, violations, interventions, time |
500
521
  | `tdd fleet [--json]` | all active runs across every worktree; read-only |
@@ -268,6 +268,26 @@ When the last declared cycle closes, the tool gathers every path that appeared i
268
268
  emitted (visible in `tdd metrics` and the friction log) and the run completes. The event
269
269
  makes a silent drop visible without blocking, since the file is already gone.
270
270
 
271
+ ### Abandoning a run
272
+
273
+ A run that will not be finished — stopped on purpose, or blocked for good — is ended with
274
+ `tdd run abandon --reason "<why>"` rather than `resume --unblock`. It takes the worktree's
275
+ live run, else its most recent blocked run. It sets the run's `outcome` to `abandoned`,
276
+ records the reason and who abandoned it (the account, and the executor identity as `run
277
+ start` resolves it; on a split runner the account is the calling agent's `SUDO_USER`),
278
+ records a `human_intervention`, and releases the worktree's baseline and advance claims.
279
+ `tdd fleet` stops listing the run, `tdd metrics` reports it as `abandoned` with an
280
+ `abandoned: {reason, by}` entry, and a new run can start at the same worktree path.
281
+
282
+ When the worktree is gone, abandon by id from any worktree of the repo: `tdd run abandon
283
+ --run <id> --reason "<why>"`. `--run` is accepted only for a run whose worktree no longer
284
+ exists or is the caller's own, so one agent cannot end another's live run.
285
+
286
+ Refusals, under `result.reason`, write nothing: `reason_required` (blank reason),
287
+ `no_run` (no live or blocked run here), `run_not_found`, `run_ended` (already complete or
288
+ abandoned), `worktree_exists` (`--run` names another worktree that still exists), and
289
+ `advance_in_flight` (a live `tdd advance` holds the worktree; a stale claim is released).
290
+
271
291
  The gate composes with `ancillary_files`: declared paths never fire `undeclared_file_touched`,
272
292
  so they are never seen by the gate.
273
293
 
@@ -467,6 +487,7 @@ and is never reclassified as a pin.
467
487
  | `tdd note "<text>"` | attach a free-text narrative note to the current cycle or run |
468
488
  | `tdd blocker --kind --detail` | typed blocker; releases the stop hook |
469
489
  | `tdd resume [--unblock --note]` | reconstruct position; human intervention |
490
+ | `tdd run abandon --reason <text> [--run <id>]` | end a run that will not be finished; human only |
470
491
  | `tdd log render [--out]` | project the ledger into a friction log |
471
492
  | `tdd metrics` | fidelity, attempts, violations, interventions, time |
472
493
  | `tdd fleet [--json]` | all active runs across every worktree; read-only |
@@ -528,6 +528,7 @@ one has no move left but to re-run doctor and read the same output again.
528
528
  | `tdd blocker --kind <k> --detail <text>` | record a typed blocker; set run `outcome = blocked`, releasing the stop hook (H1) |
529
529
  | `tdd resume` | reconstruct position from the ledger and emit `next_action` |
530
530
  | `tdd resume --unblock --note <text>` | **human only.** Reopen a blocked run, recording a `human_intervention` event with the note |
531
+ | `tdd run abandon --reason <text> [--run <id>]` | **human only.** End a live or blocked run that will not be finished: `outcome = abandoned`, an `abandonment` row (reason, account, executor), a `human_intervention` event, and the worktree's claims released. `--run` only for a run whose worktree is gone or is the caller's own |
531
532
 
532
533
  - **R8.7** A blocked run is not live. H1 must permit the agent to stop, or a blocker traps it.
533
534
  - **R8.8** `human_intervention` events are the input to interventions-per-run, the primary
@@ -3,4 +3,4 @@
3
3
  State is derived from observed test execution, never asserted by the caller.
4
4
  """
5
5
 
6
- __version__ = "0.13.0"
6
+ __version__ = "0.14.0"
@@ -1264,6 +1264,14 @@ def _accept_failures_into_baseline(
1264
1264
  return accepted, refused
1265
1265
 
1266
1266
 
1267
+ def _latest_blocked_run(ledger: Ledger, worktree: Path) -> sqlite3.Row | None:
1268
+ return ledger.one(
1269
+ "SELECT * FROM run WHERE worktree_path = ? AND outcome = 'blocked'"
1270
+ " ORDER BY id DESC LIMIT 1",
1271
+ (str(worktree),),
1272
+ )
1273
+
1274
+
1267
1275
  def cmd_resume(args) -> Envelope:
1268
1276
  worktree = _worktree()
1269
1277
  cfg = config_mod.load(worktree)
@@ -1278,11 +1286,7 @@ def cmd_resume(args) -> Envelope:
1278
1286
  if args.unblock:
1279
1287
  if run is not None:
1280
1288
  return failure("run is already live; --unblock applies to a blocked run")
1281
- blocked = ledger.one(
1282
- "SELECT * FROM run WHERE worktree_path = ? AND outcome = 'blocked'"
1283
- " ORDER BY id DESC LIMIT 1",
1284
- (str(worktree),),
1285
- )
1289
+ blocked = _latest_blocked_run(ledger, worktree)
1286
1290
  if blocked is None:
1287
1291
  return failure("no blocked run to unblock in this worktree")
1288
1292
  if not args.note:
@@ -1321,6 +1325,68 @@ def cmd_resume(args) -> Envelope:
1321
1325
  )
1322
1326
 
1323
1327
 
1328
+ def _calling_account() -> str:
1329
+ """The account that asked. On a split runner that is the agent behind sudo, which
1330
+ sets `SUDO_USER` itself; elsewhere the effective uid. Never `getpass.getuser()`: it
1331
+ trusts `LOGNAME`/`USER`, which the caller controls."""
1332
+ split = runner_mod.load()
1333
+ if split is not None and split.role == "runner" and os.environ.get("SUDO_USER"):
1334
+ return os.environ["SUDO_USER"]
1335
+ return pwd.getpwuid(os.geteuid()).pw_name
1336
+
1337
+
1338
+ def cmd_run_abandon(args) -> Envelope:
1339
+ if not args.reason.strip():
1340
+ return failure("--reason must say why the run is abandoned", reason="reason_required")
1341
+ worktree = _worktree()
1342
+ ledger = Ledger(gitutil.repo_identity(worktree))
1343
+ if args.run is not None:
1344
+ run = ledger.one("SELECT * FROM run WHERE id = ?", (args.run,))
1345
+ if run is None:
1346
+ return failure(f"no run {args.run} in this ledger", reason="run_not_found")
1347
+ if run["ended_at"] is not None and run["outcome"] != "blocked":
1348
+ return failure(f"run {args.run} already ended {run['outcome']}", reason="run_ended")
1349
+ # By id only when the worktree is gone or is the caller's own: on a split runner
1350
+ # every agent reaches the same ledger, and one must not end another's live run.
1351
+ if run["worktree_path"] != str(worktree) and Path(run["worktree_path"]).exists():
1352
+ return failure(
1353
+ f"run {args.run}'s worktree {run['worktree_path']} still exists;"
1354
+ " abandon it from there",
1355
+ reason="worktree_exists",
1356
+ )
1357
+ else:
1358
+ run = ledger.active_run(str(worktree)) or _latest_blocked_run(ledger, worktree)
1359
+ if run is None:
1360
+ return failure("no live or blocked run in this worktree", reason="no_run")
1361
+ # A stale claim is released below, not obeyed; a live one means an advance is mid-flight.
1362
+ claim = ledger.active_advance_claim(run["worktree_path"])
1363
+ if claim is not None and not claim["stale"]:
1364
+ return failure(
1365
+ f"an advance is in flight (pid {claim['pid']}); stop it first",
1366
+ reason="advance_in_flight",
1367
+ pid=claim["pid"],
1368
+ )
1369
+ executor = identity.resolve(worktree)
1370
+ at = now()
1371
+ ledger.update("run", run["id"], ended_at=at, outcome="abandoned")
1372
+ ledger.insert(
1373
+ "abandonment",
1374
+ run_id=run["id"],
1375
+ reason=args.reason,
1376
+ account=_calling_account(),
1377
+ executor_model=executor.model,
1378
+ executor_source=executor.source,
1379
+ at=at,
1380
+ )
1381
+ ledger.insert("human_intervention", run_id=run["id"], note=f"abandoned: {args.reason}", at=at)
1382
+ ledger.release_claim(run["worktree_path"])
1383
+ ledger.release_advance_claim(run["worktree_path"])
1384
+ return Envelope(
1385
+ result={"run_id": run["id"]},
1386
+ next_action=NextAction(Verb.COMPLETE, f"Run {run['id']} abandoned."),
1387
+ )
1388
+
1389
+
1324
1390
  def cmd_sensitivity(args) -> Envelope:
1325
1391
  worktree, cfg, ledger, run = _context()
1326
1392
  cycle = ledger.open_cycle(run["id"])
@@ -1709,6 +1775,11 @@ def build_parser() -> argparse.ArgumentParser:
1709
1775
  )
1710
1776
  s.set_defaults(fn=cmd_run_start)
1711
1777
 
1778
+ s = run_p.add_parser("abandon", help="end a run that will not be finished; human only")
1779
+ s.add_argument("--reason", required=True)
1780
+ s.add_argument("--run", type=int, default=None, help="a run whose worktree is gone")
1781
+ s.set_defaults(fn=cmd_run_abandon)
1782
+
1712
1783
  s = sub.add_parser("status")
1713
1784
  s.set_defaults(fn=cmd_status)
1714
1785
 
@@ -260,6 +260,16 @@ CREATE TABLE IF NOT EXISTS human_intervention (
260
260
  at TEXT NOT NULL
261
261
  );
262
262
 
263
+ CREATE TABLE IF NOT EXISTS abandonment (
264
+ id INTEGER PRIMARY KEY,
265
+ run_id INTEGER NOT NULL UNIQUE REFERENCES run(id),
266
+ reason TEXT NOT NULL,
267
+ account TEXT NOT NULL,
268
+ executor_model TEXT NOT NULL,
269
+ executor_source TEXT NOT NULL,
270
+ at TEXT NOT NULL
271
+ );
272
+
263
273
  CREATE TABLE IF NOT EXISTS baseline_cache (
264
274
  id INTEGER PRIMARY KEY,
265
275
  project TEXT NOT NULL,
@@ -441,4 +441,14 @@ def metrics(ledger: Ledger, worktree: str) -> dict:
441
441
  },
442
442
  }
443
443
  )
444
+ abandonment = ledger.one("SELECT * FROM abandonment WHERE run_id = ?", (run["id"],))
445
+ if abandonment is not None:
446
+ out["runs"][-1]["abandoned"] = {
447
+ "reason": abandonment["reason"],
448
+ "by": {
449
+ "account": abandonment["account"],
450
+ "executor": abandonment["executor_model"],
451
+ "executor_source": abandonment["executor_source"],
452
+ },
453
+ }
444
454
  return out
@@ -0,0 +1,256 @@
1
+ """`tdd run abandon`: end a run that will not be finished (issue #150)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import socket
7
+ import subprocess
8
+
9
+ from conftest import current_user, git, run_cli, write_plan
10
+ from tddcli import gitutil
11
+ from tddcli.ledger import Ledger
12
+
13
+ PLAN = """---
14
+ cycles:
15
+ - n: 1
16
+ project: backend
17
+ title: "adding"
18
+ test: "tests/test_add.py::test_add_two_numbers"
19
+ ---
20
+ # Plan
21
+ """
22
+
23
+
24
+ def ledger(repo):
25
+ return Ledger(gitutil.repo_identity(repo))
26
+
27
+
28
+ def register(repo):
29
+ plan = write_plan(repo, PLAN)
30
+ run_cli(repo, "plan", "register", plan)
31
+ return plan
32
+
33
+
34
+ def start(path, plan):
35
+ out = run_cli(path, "run", "start", "--plan", plan)
36
+ assert out["ok"], out
37
+ return out
38
+
39
+
40
+ def outcome(repo, run_id):
41
+ return ledger(repo).one("SELECT outcome FROM run WHERE id = ?", (run_id,))["outcome"]
42
+
43
+
44
+ def last_run_id(repo):
45
+ return ledger(repo).one("SELECT id FROM run ORDER BY id DESC LIMIT 1")["id"]
46
+
47
+
48
+ def add_worktree(repo, path):
49
+ git(repo, "worktree", "add", "-q", "--detach", str(path))
50
+ return path
51
+
52
+
53
+ def remove_worktree(repo, path):
54
+ git(repo, "worktree", "remove", "--force", str(path))
55
+
56
+
57
+ def test_abandon_ends_the_live_run_as_abandoned(repo):
58
+ plan = register(repo)
59
+ start(repo, plan)
60
+
61
+ run_cli(repo, "run", "abandon", "--reason", "superseded by #145")
62
+
63
+ row = ledger(repo).one("SELECT outcome, ended_at FROM run")
64
+ assert (row["outcome"], row["ended_at"] is not None) == ("abandoned", True)
65
+
66
+
67
+ def test_metrics_reports_the_reason_and_who_abandoned_the_run(repo):
68
+ plan = register(repo)
69
+ start(repo, plan)
70
+
71
+ run_cli(repo, "run", "abandon", "--reason", "superseded by #145")
72
+
73
+ entry = run_cli(repo, "metrics")["result"]["runs"][0]
74
+ assert entry.get("abandoned") == {
75
+ "reason": "superseded by #145",
76
+ "by": {
77
+ "account": current_user(),
78
+ "executor": "pytest-executor",
79
+ "executor_source": "declared",
80
+ },
81
+ }
82
+
83
+
84
+ def test_abandoning_records_a_human_intervention(repo):
85
+ plan = register(repo)
86
+ start(repo, plan)
87
+
88
+ run_cli(repo, "run", "abandon", "--reason", "superseded by #145")
89
+
90
+ notes = [r["note"] for r in ledger(repo).all("SELECT note FROM human_intervention")]
91
+ assert notes == ["abandoned: superseded by #145"]
92
+
93
+
94
+ def test_fleet_stops_listing_an_abandoned_run(repo):
95
+ plan = register(repo)
96
+ start(repo, plan)
97
+
98
+ run_cli(repo, "run", "abandon", "--reason", "superseded by #145")
99
+
100
+ assert run_cli(repo, "fleet", "--json")["result"]["runs"] == []
101
+
102
+
103
+ def test_abandon_by_id_ends_a_run_whose_worktree_is_gone(repo, tmp_path):
104
+ plan = register(repo)
105
+ wt = add_worktree(repo, tmp_path / "wt-a")
106
+ start(wt, plan)
107
+ run_id = last_run_id(repo)
108
+ remove_worktree(repo, wt)
109
+
110
+ run_cli(
111
+ repo,
112
+ "run",
113
+ "abandon",
114
+ "--run",
115
+ str(run_id),
116
+ "--reason",
117
+ "worktree removed; superseded by #145",
118
+ )
119
+
120
+ assert outcome(repo, run_id) == "abandoned"
121
+
122
+
123
+ def test_a_new_run_starts_in_a_fresh_worktree_after_abandon(repo, tmp_path):
124
+ plan = register(repo)
125
+ wt = add_worktree(repo, tmp_path / "wt-a")
126
+ start(wt, plan)
127
+ run_id = last_run_id(repo)
128
+ remove_worktree(repo, wt)
129
+ run_cli(repo, "run", "abandon", "--run", str(run_id), "--reason", "superseded by #145")
130
+
131
+ add_worktree(repo, wt)
132
+
133
+ assert run_cli(wt, "run", "start", "--plan", plan)["ok"] is True
134
+
135
+
136
+ def test_abandon_accepts_live_and_blocked_runs_in_every_accepted_form(repo, tmp_path):
137
+ plan = register(repo)
138
+
139
+ # (a) a blocked run in the caller's worktree, without --run
140
+ start(repo, plan)
141
+ a_id = last_run_id(repo)
142
+ run_cli(repo, "blocker", "--kind", "tooling", "--detail", "d")
143
+ run_cli(repo, "run", "abandon", "--reason", "blocked for good")
144
+
145
+ # (b) a blocked run by --run when its worktree is gone
146
+ wt_b = add_worktree(repo, tmp_path / "wt-b")
147
+ start(wt_b, plan)
148
+ b_id = last_run_id(repo)
149
+ run_cli(wt_b, "blocker", "--kind", "tooling", "--detail", "d")
150
+ remove_worktree(repo, wt_b)
151
+ run_cli(repo, "run", "abandon", "--run", str(b_id), "--reason", "blocked and removed")
152
+
153
+ # (c) a live run by --run from its own worktree
154
+ wt_c = add_worktree(repo, tmp_path / "wt-c")
155
+ start(wt_c, plan)
156
+ c_id = last_run_id(repo)
157
+ run_cli(wt_c, "run", "abandon", "--run", str(c_id), "--reason", "own worktree")
158
+
159
+ assert [outcome(repo, a_id), outcome(repo, b_id), outcome(repo, c_id)] == [
160
+ "abandoned",
161
+ "abandoned",
162
+ "abandoned",
163
+ ]
164
+
165
+
166
+ def test_abandon_refuses_a_blank_reason(repo):
167
+ plan = register(repo)
168
+ start(repo, plan)
169
+ run_id = last_run_id(repo)
170
+
171
+ out = run_cli(repo, "run", "abandon", "--reason", " ")
172
+
173
+ assert (out["result"].get("reason"), outcome(repo, run_id)) == ("reason_required", None)
174
+
175
+
176
+ def test_abandon_without_a_live_or_blocked_run_is_refused(repo):
177
+ plan = register(repo)
178
+ start(repo, plan)
179
+ run_cli(repo, "cycle", "skip", "--reason", "r")
180
+
181
+ out = run_cli(repo, "run", "abandon", "--reason", "nothing to end")
182
+
183
+ assert out["result"].get("reason") == "no_run"
184
+
185
+
186
+ def test_abandon_refuses_an_unknown_run_id(repo):
187
+ register(repo)
188
+
189
+ out = run_cli(repo, "run", "abandon", "--run", "999", "--reason", "x")
190
+
191
+ assert out["result"].get("reason") == "run_not_found"
192
+
193
+
194
+ def test_abandon_refuses_a_run_that_already_ended(repo):
195
+ plan = register(repo)
196
+ start(repo, plan)
197
+ a_id = last_run_id(repo)
198
+ run_cli(repo, "cycle", "skip", "--reason", "r")
199
+ start(repo, plan)
200
+ b_id = last_run_id(repo)
201
+ run_cli(repo, "run", "abandon", "--reason", "superseded")
202
+
203
+ a = run_cli(repo, "run", "abandon", "--run", str(a_id), "--reason", "x")
204
+ b = run_cli(repo, "run", "abandon", "--run", str(b_id), "--reason", "x")
205
+
206
+ assert [a["result"].get("reason"), b["result"].get("reason")] == ["run_ended", "run_ended"]
207
+
208
+
209
+ def test_abandon_by_id_refuses_a_run_whose_worktree_still_exists(repo, tmp_path):
210
+ plan = register(repo)
211
+ wt = add_worktree(repo, tmp_path / "wt-d")
212
+ start(wt, plan)
213
+ run_id = last_run_id(repo)
214
+
215
+ out = run_cli(repo, "run", "abandon", "--run", str(run_id), "--reason", "x")
216
+
217
+ assert (out["result"].get("reason"), outcome(repo, run_id)) == ("worktree_exists", None)
218
+
219
+
220
+ def test_abandoning_releases_the_worktrees_claims(repo):
221
+ plan = register(repo)
222
+ start(repo, plan)
223
+ wt = str(gitutil.worktree_root(repo))
224
+ dead = subprocess.Popen(["true"])
225
+ dead.wait()
226
+ claims = ledger(repo)
227
+ claims.claim(wt, socket.gethostname(), dead.pid, 1)
228
+ claims.claim_advance(wt, socket.gethostname(), dead.pid)
229
+
230
+ run_cli(repo, "run", "abandon", "--reason", "superseded by #145")
231
+
232
+ f = run_cli(repo, "fleet", "--json")["result"]
233
+ assert (f["collecting"], f["advancing"]) == ([], [])
234
+
235
+
236
+ def test_abandon_refuses_while_an_advance_is_in_flight(repo):
237
+ plan = register(repo)
238
+ start(repo, plan)
239
+ run_id = last_run_id(repo)
240
+ wt = str(gitutil.worktree_root(repo))
241
+ ledger(repo).claim_advance(wt, socket.gethostname(), os.getpid())
242
+
243
+ out = run_cli(repo, "run", "abandon", "--reason", "superseded by #145")
244
+
245
+ assert (out["result"].get("reason"), outcome(repo, run_id)) == ("advance_in_flight", None)
246
+
247
+
248
+ def test_a_split_runner_records_the_calling_agent_as_the_account(repo, split_runner, monkeypatch):
249
+ monkeypatch.setenv("SUDO_USER", "agent-7")
250
+ plan = register(repo)
251
+ start(repo, plan)
252
+
253
+ run_cli(repo, "run", "abandon", "--reason", "superseded by #145")
254
+
255
+ entry = run_cli(repo, "metrics")["result"]["runs"][0]
256
+ assert entry["abandoned"]["by"]["account"] == "agent-7"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes