delegate-agent-cli 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. delegate_agent/__init__.py +5 -0
  2. delegate_agent/archived_logs.py +44 -0
  3. delegate_agent/argv_builders.py +423 -0
  4. delegate_agent/argv_utils.py +36 -0
  5. delegate_agent/capability_commands.py +99 -0
  6. delegate_agent/cli.py +987 -0
  7. delegate_agent/cli_parser.py +1627 -0
  8. delegate_agent/command_errors.py +29 -0
  9. delegate_agent/command_help.py +1264 -0
  10. delegate_agent/config.py +1117 -0
  11. delegate_agent/config_commands.py +143 -0
  12. delegate_agent/constants.py +50 -0
  13. delegate_agent/describe_payload.py +1018 -0
  14. delegate_agent/errors.py +32 -0
  15. delegate_agent/git_utils.py +180 -0
  16. delegate_agent/harness_events.py +719 -0
  17. delegate_agent/inspection_commands.py +104 -0
  18. delegate_agent/isolation.py +316 -0
  19. delegate_agent/json_types.py +18 -0
  20. delegate_agent/log_output.py +78 -0
  21. delegate_agent/private_io.py +109 -0
  22. delegate_agent/profile_commands.py +42 -0
  23. delegate_agent/profile_guard.py +116 -0
  24. delegate_agent/profiles.py +389 -0
  25. delegate_agent/prompt_instructions.py +39 -0
  26. delegate_agent/prompt_transport.py +12 -0
  27. delegate_agent/reasoning.py +916 -0
  28. delegate_agent/redaction.py +193 -0
  29. delegate_agent/rendering.py +573 -0
  30. delegate_agent/request_build.py +1681 -0
  31. delegate_agent/request_models.py +191 -0
  32. delegate_agent/retention.py +321 -0
  33. delegate_agent/run_metadata.py +78 -0
  34. delegate_agent/run_output_commands.py +645 -0
  35. delegate_agent/run_registry.py +747 -0
  36. delegate_agent/run_status.py +300 -0
  37. delegate_agent/runner.py +1830 -0
  38. delegate_agent/safe_workspace.py +821 -0
  39. delegate_agent/snapshot_view.py +229 -0
  40. delegate_agent/wait_cancel_commands.py +559 -0
  41. delegate_agent/worktree_commands.py +218 -0
  42. delegate_agent/worktree_execution.py +654 -0
  43. delegate_agent/worktree_gc.py +529 -0
  44. delegate_agent/worktree_mgmt.py +782 -0
  45. delegate_agent/worktree_records.py +232 -0
  46. delegate_agent/worktree_remove.py +547 -0
  47. delegate_agent/worktree_summary.py +242 -0
  48. delegate_agent/wsl.py +65 -0
  49. delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
  50. delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
  51. delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
  52. delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
  53. delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
  54. delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,529 @@
1
+ """Worktree prune and gc pipelines plus opportunistic auto-prune.
2
+
3
+ Implements ``delegate worktree prune`` (filtered batch removal), ``delegate
4
+ worktree gc`` (registry reconciliation against the live ``git worktree list``),
5
+ and the ``maybe_auto_prune`` hook fired opportunistically from ``worktree list``.
6
+ ``worktree_mgmt`` re-exports this surface so callers and tests keep importing
7
+ from ``worktree_mgmt``.
8
+
9
+ Cross-cutting seams monkeypatched on the ``worktree_mgmt`` module
10
+ (``detect_worktree_status``, ``dirty_info``, ``merged_into_source``,
11
+ ``_worktree_list_paths_with_warning``, ``prune_worktrees``, ``remove_worktree``,
12
+ ``_error_payload``) are read back through the ``worktree_mgmt`` facade (the
13
+ ``wm`` alias) at call time so those patches still take effect.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from datetime import UTC, datetime, timedelta
20
+ from pathlib import Path
21
+
22
+ from delegate_agent import run_registry
23
+ from delegate_agent.json_types import JsonObject, is_non_negative_int
24
+ from delegate_agent.worktree_records import (
25
+ SCHEMA_GC,
26
+ SCHEMA_PRUNE,
27
+ STATUS_MISSING,
28
+ STATUS_PRESENT,
29
+ STATUS_REMOVED,
30
+ STATUS_UNKNOWN,
31
+ WORKTREE_ERROR_EXIT_CODE,
32
+ PersistentWorktreeRecord,
33
+ _registered_worktree_path_matches,
34
+ _reload_record,
35
+ load_persistent_records,
36
+ )
37
+
38
+
39
+ def _older_than(record: PersistentWorktreeRecord, days: int) -> bool | None:
40
+ timestamp = record.get("lastActivityAt")
41
+ dt = run_registry.parse_utc_timestamp(timestamp if isinstance(timestamp, str) else None)
42
+ if dt is None:
43
+ return None
44
+ return dt < datetime.now(UTC) - timedelta(days=days)
45
+
46
+
47
+ def _entry_ref(record: PersistentWorktreeRecord, *, reason: str | None = None) -> JsonObject:
48
+ entry: JsonObject = {"alias": record.get("alias"), "runId": record.get("runId")}
49
+ if reason is not None:
50
+ entry["reason"] = reason
51
+ return entry
52
+
53
+
54
+ def prune_worktrees(
55
+ registry_root: Path,
56
+ *,
57
+ merged: bool = False,
58
+ older_than_days: int | None = None,
59
+ harness: str | None = None,
60
+ group: str | None = None,
61
+ include_detached: bool = False,
62
+ dry_run: bool = False,
63
+ discard_uncommitted: bool = False,
64
+ force_branch: bool = False,
65
+ force: bool = False,
66
+ ) -> JsonObject:
67
+ if not merged and older_than_days is None:
68
+ raise wm.WorktreeManagementError(
69
+ wm._error_payload(
70
+ "prune_filter_required",
71
+ "delegate worktree prune requires --merged and/or --older-than DAYS.",
72
+ )
73
+ )
74
+ if force:
75
+ discard_uncommitted = True
76
+ force_branch = True
77
+ planned: list[JsonObject] = []
78
+ removed: list[JsonObject] = []
79
+ skipped: list[JsonObject] = []
80
+ errors: list[JsonObject] = []
81
+ for record in load_persistent_records(registry_root):
82
+ alias = record.get("alias")
83
+ if harness is not None and record.get("harness") != harness:
84
+ skipped.append(_entry_ref(record, reason="harness_filter"))
85
+ continue
86
+ if group is not None and record.get("group") != group:
87
+ skipped.append(_entry_ref(record, reason="group_filter"))
88
+ continue
89
+ status, _warnings = wm.detect_worktree_status(record)
90
+ if status in (STATUS_REMOVED, STATUS_UNKNOWN):
91
+ # Not candidates — filter silently (spec L678).
92
+ continue
93
+ if status == STATUS_MISSING:
94
+ skipped.append(_entry_ref(record, reason="path_missing"))
95
+ continue
96
+ creation = record.get("creationContext")
97
+ if (
98
+ merged
99
+ and isinstance(creation, dict)
100
+ and creation.get("sourceHeadRef") is None
101
+ and not include_detached
102
+ ):
103
+ skipped.append(_entry_ref(record, reason="detached_source"))
104
+ continue
105
+ # Compute dirty before the merged check so the merged branch path
106
+ # can test dirty state without rebinding a later assignment.
107
+ dirty, _dirty_paths, _dirty_total, _dirty_warnings = wm.dirty_info(record, status)
108
+ if dirty is None and not discard_uncommitted:
109
+ skipped.append(_entry_ref(record, reason="dirty_check_failed"))
110
+ continue
111
+ if dirty is True and not discard_uncommitted:
112
+ skipped.append(_entry_ref(record, reason="dirty"))
113
+ continue
114
+ keep_branch_for_prune = False
115
+ merged_check_already_passed = False
116
+ if merged:
117
+ merged_value, _merge_warnings = wm.merged_into_source(
118
+ record,
119
+ status,
120
+ include_detached=include_detached,
121
+ )
122
+ merged_check_already_passed = merged_value is True
123
+ if merged_value is None:
124
+ skipped.append(_entry_ref(record, reason="merge_check_failed"))
125
+ continue
126
+ if merged_value is False and not force_branch:
127
+ if dirty is not True:
128
+ keep_branch_for_prune = True
129
+ else:
130
+ skipped.append(_entry_ref(record, reason="unmerged_branch"))
131
+ continue
132
+ if older_than_days is not None:
133
+ old_enough = _older_than(record, older_than_days)
134
+ if old_enough is None:
135
+ skipped.append(_entry_ref(record, reason="invalid_last_activity"))
136
+ continue
137
+ if not old_enough:
138
+ skipped.append(_entry_ref(record, reason="not_yet_old_enough"))
139
+ continue
140
+ candidate = {
141
+ "alias": alias,
142
+ "runId": record.get("runId"),
143
+ "branch": record.get("branch"),
144
+ "executionCwd": record.get("executionCwd"),
145
+ "sourceGitRoot": record.get("sourceGitRoot"),
146
+ }
147
+ if keep_branch_for_prune:
148
+ candidate["keep_branch"] = True
149
+ planned.append(candidate)
150
+ if not dry_run:
151
+ try:
152
+ removed.append(
153
+ wm.remove_worktree(
154
+ registry_root,
155
+ handle=str(alias or record.get("runId")),
156
+ discard_uncommitted=discard_uncommitted,
157
+ force_branch=force_branch,
158
+ keep_branch=candidate.get("keep_branch", False),
159
+ _merged_check_already_passed=merged_check_already_passed,
160
+ )
161
+ )
162
+ if removed[-1].get("ok") is False:
163
+ errors.append(removed[-1])
164
+ except wm.WorktreeManagementError as exc:
165
+ errors.append(exc.payload)
166
+ payload = {
167
+ "schema": SCHEMA_PRUNE,
168
+ "ok": not errors,
169
+ "planned": planned,
170
+ "removed": removed,
171
+ "skipped": skipped,
172
+ "errors": errors,
173
+ "dryRun": dry_run,
174
+ }
175
+ if errors:
176
+ payload["exitCode"] = WORKTREE_ERROR_EXIT_CODE
177
+ return payload
178
+
179
+
180
+ def _reload_gc_candidate(
181
+ registry_root: Path,
182
+ record: PersistentWorktreeRecord,
183
+ ) -> tuple[PersistentWorktreeRecord, str, str] | None:
184
+ fresh = _reload_record(registry_root, str(record["runId"]))
185
+ if fresh is None:
186
+ return None
187
+ fresh_current = fresh.get("registryWorktreeStatus")
188
+ if fresh_current not in (STATUS_PRESENT, STATUS_UNKNOWN, None):
189
+ return None
190
+ fresh_source = fresh.get("sourceGitRoot")
191
+ fresh_execution = fresh.get("executionCwd")
192
+ if not isinstance(fresh_source, str) or not isinstance(fresh_execution, str):
193
+ return None
194
+ return fresh, fresh_source, fresh_execution
195
+
196
+
197
+ GcFreshAction = Callable[[PersistentWorktreeRecord, str, str], None]
198
+
199
+
200
+ def _with_locked_fresh_gc_candidate(
201
+ registry_root: Path,
202
+ record: PersistentWorktreeRecord,
203
+ action: GcFreshAction,
204
+ ) -> None:
205
+ with run_registry.registry_lock(registry_root):
206
+ fresh_candidate = _reload_gc_candidate(registry_root, record)
207
+ if fresh_candidate is None:
208
+ return
209
+ action(*fresh_candidate)
210
+
211
+
212
+ def _gc_missing_entry(
213
+ record: PersistentWorktreeRecord,
214
+ execution: str,
215
+ *,
216
+ dry_run: bool,
217
+ ) -> JsonObject:
218
+ return {
219
+ "alias": record.get("alias"),
220
+ "runId": record.get("runId"),
221
+ "executionCwd": execution,
222
+ "worktreeStatus": STATUS_MISSING,
223
+ "reason": "path_missing",
224
+ "action": "would_mark_missing" if dry_run else "marked_missing",
225
+ }
226
+
227
+
228
+ def _gc_orphan_entry(
229
+ record: PersistentWorktreeRecord,
230
+ execution: str,
231
+ reason: str,
232
+ message: str | None = None,
233
+ ) -> JsonObject:
234
+ entry = {
235
+ "alias": record.get("alias"),
236
+ "runId": record.get("runId"),
237
+ "executionCwd": execution,
238
+ "branch": record.get("branch"),
239
+ "reason": reason,
240
+ }
241
+ if message:
242
+ entry["message"] = message
243
+ safe_actions = {
244
+ "worktree_metadata_missing": "inspect_path_before_manual_cleanup",
245
+ "branch_missing": "inspect_branch_metadata_before_manual_cleanup",
246
+ "worktree_list_failed": "retry_after_git_worktree_list_succeeds",
247
+ }
248
+ if reason in safe_actions:
249
+ entry["safeAction"] = safe_actions[reason]
250
+ return entry
251
+
252
+
253
+ def _gc_reconcile_missing_path(
254
+ registry_root: Path,
255
+ record: PersistentWorktreeRecord,
256
+ execution: str,
257
+ *,
258
+ dry_run: bool,
259
+ reconciled: list[JsonObject],
260
+ prune_roots: set[str],
261
+ ) -> bool:
262
+ if Path(execution).exists():
263
+ return False
264
+ if dry_run:
265
+ reconciled.append(_gc_missing_entry(record, execution, dry_run=True))
266
+ return True
267
+
268
+ def mark_missing(
269
+ fresh: PersistentWorktreeRecord, fresh_source: str, fresh_execution: str
270
+ ) -> None:
271
+ if Path(fresh_execution).exists():
272
+ return
273
+ run_registry.set_worktree_status_locked(
274
+ registry_root,
275
+ str(fresh["runId"]),
276
+ STATUS_MISSING,
277
+ )
278
+ prune_roots.add(fresh_source)
279
+ reconciled.append(_gc_missing_entry(fresh, fresh_execution, dry_run=False))
280
+
281
+ _with_locked_fresh_gc_candidate(registry_root, record, mark_missing)
282
+ return True
283
+
284
+
285
+ def _gc_reconcile_list_failure(
286
+ registry_root: Path,
287
+ record: PersistentWorktreeRecord,
288
+ execution: str,
289
+ *,
290
+ listed_paths: set[str] | None,
291
+ list_warning: str | None,
292
+ dry_run: bool,
293
+ orphans: list[JsonObject],
294
+ ) -> bool:
295
+ if listed_paths is not None:
296
+ return False
297
+ if dry_run:
298
+ orphans.append(_gc_orphan_entry(record, execution, "worktree_list_failed", list_warning))
299
+ return True
300
+
301
+ def mark_unknown(
302
+ fresh: PersistentWorktreeRecord, _fresh_source: str, fresh_execution: str
303
+ ) -> None:
304
+ if not Path(fresh_execution).exists():
305
+ return
306
+ run_registry.set_worktree_status_locked(
307
+ registry_root,
308
+ str(fresh["runId"]),
309
+ STATUS_UNKNOWN,
310
+ )
311
+ orphans.append(
312
+ _gc_orphan_entry(fresh, fresh_execution, "worktree_list_failed", list_warning)
313
+ )
314
+
315
+ _with_locked_fresh_gc_candidate(registry_root, record, mark_unknown)
316
+ return True
317
+
318
+
319
+ def _gc_reconcile_missing_metadata(
320
+ registry_root: Path,
321
+ record: PersistentWorktreeRecord,
322
+ execution: str,
323
+ *,
324
+ listed_paths: set[str] | None,
325
+ dry_run: bool,
326
+ orphans: list[JsonObject],
327
+ warnings: list[JsonObject],
328
+ ) -> bool:
329
+ if listed_paths is None or _registered_worktree_path_matches(listed_paths, execution):
330
+ return False
331
+ if dry_run:
332
+ orphans.append(_gc_orphan_entry(record, execution, "worktree_metadata_missing"))
333
+ return True
334
+
335
+ def mark_unknown_if_still_orphaned(
336
+ fresh: PersistentWorktreeRecord,
337
+ fresh_source: str,
338
+ fresh_execution: str,
339
+ ) -> None:
340
+ fresh_paths, warning = wm._worktree_list_paths_with_warning(fresh_source)
341
+ if warning is not None:
342
+ warnings.append({"sourceGitRoot": fresh_source, "message": warning})
343
+ if (
344
+ Path(fresh_execution).exists()
345
+ and fresh_paths is not None
346
+ and not _registered_worktree_path_matches(fresh_paths, fresh_execution)
347
+ ):
348
+ run_registry.set_worktree_status_locked(
349
+ registry_root,
350
+ str(fresh["runId"]),
351
+ STATUS_UNKNOWN,
352
+ )
353
+ orphans.append(_gc_orphan_entry(fresh, fresh_execution, "worktree_metadata_missing"))
354
+
355
+ _with_locked_fresh_gc_candidate(registry_root, record, mark_unknown_if_still_orphaned)
356
+ return True
357
+
358
+
359
+ def _gc_reconcile_missing_branch(
360
+ registry_root: Path,
361
+ record: PersistentWorktreeRecord,
362
+ source: str,
363
+ execution: str,
364
+ branch: str | None,
365
+ *,
366
+ dry_run: bool,
367
+ orphans: list[JsonObject],
368
+ ) -> bool:
369
+ if not isinstance(branch, str) or wm._branch_exists(source, branch) is not False:
370
+ return False
371
+ if dry_run:
372
+ orphans.append(_gc_orphan_entry(record, execution, "branch_missing"))
373
+ return True
374
+
375
+ def mark_unknown_if_branch_still_missing(
376
+ fresh: PersistentWorktreeRecord,
377
+ fresh_source: str,
378
+ fresh_execution: str,
379
+ ) -> None:
380
+ fresh_branch = fresh.get("branch")
381
+ if (
382
+ isinstance(fresh_branch, str)
383
+ and Path(fresh_execution).exists()
384
+ and wm._branch_exists(fresh_source, fresh_branch) is False
385
+ ):
386
+ run_registry.set_worktree_status_locked(
387
+ registry_root,
388
+ str(fresh["runId"]),
389
+ STATUS_UNKNOWN,
390
+ )
391
+ orphans.append(_gc_orphan_entry(fresh, fresh_execution, "branch_missing"))
392
+
393
+ _with_locked_fresh_gc_candidate(registry_root, record, mark_unknown_if_branch_still_missing)
394
+ return True
395
+
396
+
397
+ def gc_worktrees(registry_root: Path, *, dry_run: bool = False) -> JsonObject:
398
+ records = load_persistent_records(registry_root)
399
+ paths_by_root: dict[str, set[str] | None] = {}
400
+ prune_roots: set[str] = set()
401
+ reconciled: list[JsonObject] = []
402
+ orphans: list[JsonObject] = []
403
+ warnings: list[JsonObject] = []
404
+
405
+ for record in records:
406
+ current = record.get("registryWorktreeStatus")
407
+ if current not in (STATUS_PRESENT, STATUS_UNKNOWN, None):
408
+ continue
409
+ source = record.get("sourceGitRoot")
410
+ execution = record.get("executionCwd")
411
+ branch = record.get("branch")
412
+ if not isinstance(source, str) or not isinstance(execution, str):
413
+ continue
414
+ if source not in paths_by_root:
415
+ listed, warning = wm._worktree_list_paths_with_warning(source)
416
+ paths_by_root[source] = listed
417
+ if warning is not None:
418
+ warnings.append({"sourceGitRoot": source, "message": warning})
419
+ listed_paths = paths_by_root[source]
420
+ list_warning = next(
421
+ (
422
+ str(warning.get("message"))
423
+ for warning in warnings
424
+ if warning.get("sourceGitRoot") == source
425
+ and isinstance(warning.get("message"), str)
426
+ ),
427
+ None,
428
+ )
429
+ if _gc_reconcile_missing_path(
430
+ registry_root,
431
+ record,
432
+ execution,
433
+ dry_run=dry_run,
434
+ reconciled=reconciled,
435
+ prune_roots=prune_roots,
436
+ ):
437
+ continue
438
+ if _gc_reconcile_list_failure(
439
+ registry_root,
440
+ record,
441
+ execution,
442
+ listed_paths=listed_paths,
443
+ list_warning=list_warning,
444
+ dry_run=dry_run,
445
+ orphans=orphans,
446
+ ):
447
+ continue
448
+ if _gc_reconcile_missing_metadata(
449
+ registry_root,
450
+ record,
451
+ execution,
452
+ listed_paths=listed_paths,
453
+ dry_run=dry_run,
454
+ orphans=orphans,
455
+ warnings=warnings,
456
+ ):
457
+ continue
458
+ _gc_reconcile_missing_branch(
459
+ registry_root,
460
+ record,
461
+ source,
462
+ execution,
463
+ branch,
464
+ dry_run=dry_run,
465
+ orphans=orphans,
466
+ )
467
+ if not dry_run:
468
+ for source in prune_roots:
469
+ wm._run_git(source, ["worktree", "prune"])
470
+ return {
471
+ "schema": SCHEMA_GC,
472
+ "ok": True,
473
+ "dryRun": dry_run,
474
+ "mode": "dry-run" if dry_run else "reconcile-registry",
475
+ "effects": {
476
+ "registryWrites": not dry_run,
477
+ "deletesWorktreePaths": False,
478
+ "runsGitWorktreePrune": (not dry_run and bool(prune_roots)),
479
+ },
480
+ "prunedSourceRoots": 0 if dry_run else len(prune_roots),
481
+ "reconciled": len(reconciled),
482
+ "reconciledEntries": reconciled,
483
+ "orphans": orphans,
484
+ "warnings": warnings,
485
+ }
486
+
487
+
488
+ def maybe_auto_prune(
489
+ registry_root: Path,
490
+ config: JsonObject,
491
+ *,
492
+ no_auto_prune: bool = False,
493
+ ) -> JsonObject | None:
494
+ if no_auto_prune:
495
+ return None
496
+ worktrees_cfg = config.get("worktrees")
497
+ if not isinstance(worktrees_cfg, dict):
498
+ return None
499
+ auto = worktrees_cfg.get("autoPrune")
500
+ if not isinstance(auto, dict) or auto.get("enabled") is not True:
501
+ return None
502
+ days = auto.get("mergedOlderThanDays", 7)
503
+ if not is_non_negative_int(days):
504
+ days = 7
505
+ try:
506
+ # Probe lock availability without blocking. The actual prune uses
507
+ # normal per-entry mutation locks so list doesn't monopolize the
508
+ # registry across a large cleanup pass.
509
+ with run_registry.registry_lock(registry_root, timeout_seconds=0.0):
510
+ pass
511
+ except TimeoutError:
512
+ return {"ok": False, "skipped": True, "reason": "lock_contended"}
513
+ try:
514
+ return wm.prune_worktrees(
515
+ registry_root,
516
+ merged=True,
517
+ older_than_days=days,
518
+ dry_run=False,
519
+ )
520
+ except wm.WorktreeManagementError as exc:
521
+ return dict(exc.payload)
522
+
523
+
524
+ # Deferred to the bottom to break the worktree_mgmt<->worktree_gc facade cycle:
525
+ # worktree_mgmt re-exports this module's surface (a top-level import here would
526
+ # fail when worktree_gc is imported first). All `wm.<seam>` access above is
527
+ # call-time, so binding the alias after our own definitions is sufficient and
528
+ # keeps the monkeypatch seams (mock.patch.object(worktree_mgmt, ...)) working.
529
+ from delegate_agent import worktree_mgmt as wm # noqa: E402