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.
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/CHANGELOG.md +13 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/PKG-INFO +22 -1
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/README.md +21 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/docs/PRD.md +1 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/cli.py +76 -5
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/ledger.py +10 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/render.py +10 -0
- tdd_cli-0.14.0/tests/test_run_abandon.py +256 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/.gitignore +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/LICENSE +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/SECURITY.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/docs/harness-integration.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/docs/split-runner.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/plan.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/pyproject.toml +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/actor.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/__init__.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/base.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/cargo_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/exec_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/gradle_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/adapters/xctest_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/agentfs.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/config.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/docs.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/plan_paths.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/runner.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/src/tddcli/target_lint.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/conftest.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_actor_seams.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_advance_adoption.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_ancillary_files.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_baseline_sanity.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_batch_collection.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_cargo_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_close_sweep_gates.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_concurrent_advance.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_contract.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_docs_command.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_doctor_attribution.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_doctor_blockers.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_evidence_extraction.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_exec_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_executor_attribution.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_executor_notes.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_failure_clipping.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_fleet.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_gradle_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_id_normalisation.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_late_baseline.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_named_leases_and_timeout.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_plan_paths.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_progress.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_project_env.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_pytest_xdist_group.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_runner_import.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_sensitivity_evidence.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_client.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_config.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_doctor.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_identity.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_ledger.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_split_runner.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_sudo_actor.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_suite_overrides.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_suite_scope.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_target_lint.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_target_only_runs.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_target_validation.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_time_report.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_timing_visibility.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_tree_hash.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_undeclared_close_gate.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_undeclared_dedup.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_vitest_adapter.py +0 -0
- {tdd_cli-0.13.0 → tdd_cli-0.14.0}/tests/test_worker_leases.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|