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,191 @@
1
+ """Dataclasses describing parsed CLI commands and built engine requests.
2
+
3
+ A dependency leaf for the CLI layer: it depends only on the command-argument
4
+ modules, isolation context, and prompt-transport constants, never on ``cli``,
5
+ so the parser, request builder, and argv builders can all share these models
6
+ without an import cycle.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from delegate_agent import (
14
+ capability_commands,
15
+ config_commands,
16
+ inspection_commands,
17
+ profile_commands,
18
+ profiles,
19
+ run_output_commands,
20
+ wait_cancel_commands,
21
+ worktree_commands,
22
+ )
23
+ from delegate_agent.isolation import IsolationContext
24
+ from delegate_agent.json_types import JsonObject
25
+ from delegate_agent.prompt_transport import PROMPT_TRANSPORT_ARGV
26
+
27
+
28
+ @dataclass
29
+ class GlobalOptions:
30
+ json_mode: bool = False
31
+ cwd: str | None = None
32
+ pass_through: bool = False
33
+ completion_report: str | None = None
34
+ isolation: str | None = None
35
+ auth_profile: str | None = None
36
+ group: str | None = None
37
+
38
+
39
+ @dataclass
40
+ class LaunchOptions:
41
+ engine: str | None
42
+ mode: str | None
43
+ model_alias: str | None = None
44
+ prompt_parts: list[str] | None = None
45
+ prompt_file: str | None = None
46
+ output_schema: str | None = None
47
+ reasoning_effort: str | None = None
48
+ progress_intent: str | None = None
49
+ forbid_commit: bool = False
50
+ forbid_commit_implied_isolation: bool = False
51
+ include_dirty: bool = False
52
+ read_only: bool = False
53
+ dry_run: bool = False
54
+
55
+
56
+ @dataclass
57
+ class RunJsonOptions:
58
+ input_json: str
59
+
60
+
61
+ @dataclass
62
+ class InspectionOptions:
63
+ summary: bool = False
64
+
65
+
66
+ @dataclass(init=False)
67
+ class ParsedCommand:
68
+ subcommand: str
69
+ global_options: GlobalOptions
70
+ help_topic: str | None = None
71
+ launch: LaunchOptions | None = None
72
+ run_json: RunJsonOptions | None = None
73
+ snapshot: inspection_commands.SnapshotCommand | None = None
74
+ runs: inspection_commands.RunsCommand | None = None
75
+ run_output: run_output_commands.RunOutputCommand | None = None
76
+ wait_command: wait_cancel_commands.WaitCommand | None = None
77
+ cancel_command: wait_cancel_commands.CancelCommand | None = None
78
+ worktree: worktree_commands.WorktreeCommand | None = None
79
+ config_command: config_commands.ConfigCommand | None = None
80
+ capabilities: capability_commands.CapabilitiesCommand | None = None
81
+ profiles_command: profile_commands.ProfilesCommand | None = None
82
+ inspection: InspectionOptions | None = None
83
+
84
+ def __init__(
85
+ self,
86
+ subcommand: str,
87
+ *,
88
+ help_topic: str | None = None,
89
+ global_options: GlobalOptions | None = None,
90
+ launch: LaunchOptions | None = None,
91
+ run_json: RunJsonOptions | None = None,
92
+ snapshot: inspection_commands.SnapshotCommand | None = None,
93
+ runs: inspection_commands.RunsCommand | None = None,
94
+ run_output: run_output_commands.RunOutputCommand | None = None,
95
+ wait_command: wait_cancel_commands.WaitCommand | None = None,
96
+ cancel_command: wait_cancel_commands.CancelCommand | None = None,
97
+ worktree: worktree_commands.WorktreeCommand | None = None,
98
+ config_command: config_commands.ConfigCommand | None = None,
99
+ capabilities: capability_commands.CapabilitiesCommand | None = None,
100
+ profiles_command: profile_commands.ProfilesCommand | None = None,
101
+ inspection: InspectionOptions | None = None,
102
+ ) -> None:
103
+ self.subcommand = subcommand
104
+ self.global_options = global_options or GlobalOptions()
105
+ self.help_topic = help_topic
106
+ self.launch = launch
107
+ self.run_json = run_json
108
+ self.snapshot = snapshot
109
+ self.runs = runs
110
+ self.run_output = run_output
111
+ self.wait_command = wait_command
112
+ self.cancel_command = cancel_command
113
+ self.worktree = worktree
114
+ self.config_command = config_command
115
+ self.capabilities = capabilities
116
+ self.profiles_command = profiles_command
117
+ self.inspection = inspection
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class ResolvedWorkspace:
122
+ path: str
123
+ kind: str
124
+
125
+
126
+ @dataclass
127
+ class Request:
128
+ engine: str
129
+ mode: str
130
+ workspace: str
131
+ prompt: str
132
+ argv: list[str]
133
+ model: str | None
134
+ model_alias: str | None = None
135
+ output_schema: str | None = None
136
+ dry_run: bool = False
137
+ workspace_kind: str = "git"
138
+ isolation_context: IsolationContext | None = None
139
+ reasoning_effort: str | None = None
140
+ reasoning_effort_source: str | None = None
141
+ reasoning_capability_source: str | None = None
142
+ reasoning_transport: str | None = None
143
+ progress: bool = False
144
+ progress_initial_delay_sec: float = 30.0
145
+ progress_interval_sec: float = 60.0
146
+ forbid_commit: bool = False
147
+ warnings: tuple[str, ...] = ()
148
+ stdin_text: str | None = None
149
+ prompt_file_text: str | None = None
150
+ prompt_transport: str = PROMPT_TRANSPORT_ARGV
151
+ display_argv: list[str] | None = None
152
+ env_overrides: dict[str, str] | None = None
153
+ auth_profile: str | None = None
154
+ fallback_auth_profile: str | None = None
155
+ cleanup_workspace: bool = False
156
+ include_dirty: bool = False
157
+ group: str | None = None
158
+ profile_resolution: profiles.ProfileResolution = field(
159
+ default_factory=profiles.empty_profile_resolution
160
+ )
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class EngineRequestParts:
165
+ model: str | None
166
+ argv: list[str]
167
+ model_alias: str | None = None
168
+ prompt_transport: str = PROMPT_TRANSPORT_ARGV
169
+ display_argv: list[str] | None = None
170
+ warnings: tuple[str, ...] = ()
171
+ stdin_text: str | None = None
172
+ prompt_file_text: str | None = None
173
+ reasoning_effort: str | None = None
174
+ reasoning_effort_source: str | None = None
175
+ reasoning_capability_source: str | None = None
176
+ reasoning_transport: str | None = None
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class EngineBuildInput:
181
+ mode: str
182
+ model_alias: str | None
183
+ resolved: ResolvedWorkspace
184
+ prompt: str
185
+ config: JsonObject
186
+ stream_capture: bool
187
+ requested_effort: str | None
188
+ effort_source: str | None
189
+ cache: JsonObject | None
190
+ output_schema: str | None = None
191
+ call_read_only: bool = False
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tarfile
5
+ from collections import deque
6
+ from datetime import UTC, datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import BinaryIO
9
+
10
+ from delegate_agent import archived_logs, log_output, run_registry
11
+ from delegate_agent.json_types import JsonObject, is_non_negative_int
12
+ from delegate_agent.log_output import LogOutput
13
+
14
+ ARCHIVE_MEMBER_NAMES = (
15
+ run_registry.STDOUT_LOG,
16
+ run_registry.STDERR_LOG,
17
+ run_registry.EVENTS_JSONL,
18
+ )
19
+ DEFAULT_RAW_LOG_RETENTION_DAYS = 7
20
+
21
+
22
+ def archive_dir(registry_root: Path) -> Path:
23
+ return archived_logs.archive_dir(registry_root)
24
+
25
+
26
+ def archive_path(registry_root: Path, run_id: str) -> Path:
27
+ return archived_logs.archive_path(registry_root, run_id)
28
+
29
+
30
+ def retention_settings(config: JsonObject) -> JsonObject:
31
+ tracking = config.get("tracking")
32
+ if not isinstance(tracking, dict):
33
+ return {}
34
+ retention = tracking.get("retention")
35
+ return retention if isinstance(retention, dict) else {}
36
+
37
+
38
+ def retention_enabled(config: JsonObject) -> bool:
39
+ enabled = retention_settings(config).get("enabled", True)
40
+ return enabled if isinstance(enabled, bool) else True
41
+
42
+
43
+ def raw_log_retention_days(config: JsonObject) -> int:
44
+ settings = retention_settings(config)
45
+ days = settings.get("rawLogDays", DEFAULT_RAW_LOG_RETENTION_DAYS)
46
+ if not is_non_negative_int(days):
47
+ return DEFAULT_RAW_LOG_RETENTION_DAYS
48
+ return days
49
+
50
+
51
+ def should_skip_archival(registry_root: Path, run_id: str) -> bool:
52
+ state = run_registry.load_run_state_or_none(registry_root, run_id)
53
+ status = run_registry.effective_status(state)
54
+ return status in (
55
+ run_registry.STATUS_RUNNING,
56
+ run_registry.STATUS_STALE,
57
+ run_registry.STATUS_UNKNOWN,
58
+ )
59
+
60
+
61
+ def raw_logs_present(run_path: Path) -> bool:
62
+ return any((run_path / name).exists() for name in ARCHIVE_MEMBER_NAMES)
63
+
64
+
65
+ def raw_logs_archived(registry_root: Path, run_id: str) -> bool:
66
+ return archive_path(registry_root, run_id).exists()
67
+
68
+
69
+ def _validate_archive_member_name(member_name: str) -> None:
70
+ if member_name not in ARCHIVE_MEMBER_NAMES:
71
+ raise ValueError(f"unsupported archive member: {member_name}")
72
+
73
+
74
+ def _expected_member_sizes(run_path: Path, members: list[str]) -> dict[str, int]:
75
+ return {name: (run_path / name).stat().st_size for name in members}
76
+
77
+
78
+ def _verify_archive_members(archive_file: Path, expected_sizes: dict[str, int]) -> bool:
79
+ if not expected_sizes:
80
+ return False
81
+ with tarfile.open(archive_file, "r:gz") as archive:
82
+ found: dict[str, int] = {}
83
+ for member in archive.getmembers():
84
+ if member.name in expected_sizes:
85
+ found[member.name] = member.size
86
+ return all(name in found and found[name] == size for name, size in expected_sizes.items())
87
+
88
+
89
+ def _tail_text_stream_output(stream: BinaryIO, lines: int) -> LogOutput:
90
+ if lines < 1:
91
+ raise ValueError("tail lines must be at least 1")
92
+ buffer: deque[str] = deque(maxlen=lines)
93
+ total_lines = 0
94
+ for raw_line in stream:
95
+ total_lines += 1
96
+ buffer.append(raw_line.decode("utf-8", errors="replace").rstrip("\r\n"))
97
+ if not buffer:
98
+ return LogOutput(content="", truncated=False)
99
+ return LogOutput(content="\n".join(buffer) + "\n", truncated=total_lines > lines)
100
+
101
+
102
+ def effective_log_byte_sizes(registry_root: Path, run_id: str) -> tuple[int, int]:
103
+ return run_registry.effective_log_byte_sizes(registry_root, run_id)
104
+
105
+
106
+ def archived_log_warning(alias: str | None, run_id: str, *, cwd: str | None = None) -> str:
107
+ handle = alias or run_id
108
+ return (
109
+ f"raw logs archived for {handle}; use "
110
+ f"{run_registry.run_output_command(handle, cwd=cwd)} --stdout|--stderr --tail N or --raw"
111
+ )
112
+
113
+
114
+ def is_eligible_for_archival(
115
+ registry_root: Path,
116
+ run_id: str,
117
+ *,
118
+ config: JsonObject,
119
+ now: datetime | None = None,
120
+ ) -> bool:
121
+ if not retention_enabled(config):
122
+ return False
123
+ if should_skip_archival(registry_root, run_id):
124
+ return False
125
+ run_path = run_registry.run_directory(registry_root, run_id)
126
+ archive_exists = archive_path(registry_root, run_id).exists()
127
+ logs_present = raw_logs_present(run_path)
128
+ if archive_exists and not logs_present:
129
+ return False
130
+ if not archive_exists and not logs_present:
131
+ return False
132
+ moment = now or datetime.now(UTC)
133
+ activity = run_registry.activity_datetime(
134
+ run_registry.load_run_state_or_none(registry_root, run_id),
135
+ run_registry.load_run_manifest_or_none(registry_root, run_id),
136
+ run_id,
137
+ )
138
+ if activity is None:
139
+ return False
140
+ cutoff = moment - timedelta(days=raw_log_retention_days(config))
141
+ return activity < cutoff
142
+
143
+
144
+ def _mark_raw_logs_archived(
145
+ run_path: Path,
146
+ *,
147
+ stdout_bytes: int = 0,
148
+ stderr_bytes: int = 0,
149
+ ) -> None:
150
+ state_path = run_path / run_registry.STATE_FILE
151
+ state = run_registry.read_json_object_or_none(state_path)
152
+ if state is None:
153
+ state = {}
154
+ state["rawLogsArchivedAt"] = run_registry.utc_now_iso()
155
+ state["stdoutBytes"] = stdout_bytes
156
+ state["stderrBytes"] = stderr_bytes
157
+ run_registry.write_json_atomic(state_path, state)
158
+
159
+
160
+ def _remove_archived_raw_logs(run_path: Path, members: list[str]) -> None:
161
+ for name in members:
162
+ (run_path / name).unlink()
163
+
164
+
165
+ def _complete_archival_after_archive(
166
+ registry_root: Path,
167
+ run_id: str,
168
+ *,
169
+ destination: Path,
170
+ members: list[str],
171
+ ) -> bool:
172
+ run_path = run_registry.run_directory(registry_root, run_id)
173
+ if not members:
174
+ return False
175
+ expected_sizes = _expected_member_sizes(run_path, members)
176
+ if not _verify_archive_members(destination, expected_sizes):
177
+ return False
178
+ _remove_archived_raw_logs(run_path, members)
179
+ _mark_raw_logs_archived(
180
+ run_path,
181
+ stdout_bytes=expected_sizes.get(run_registry.STDOUT_LOG, 0),
182
+ stderr_bytes=expected_sizes.get(run_registry.STDERR_LOG, 0),
183
+ )
184
+ return True
185
+
186
+
187
+ def archive_run_raw_logs(registry_root: Path, run_id: str) -> bool:
188
+ run_path = run_registry.run_directory(registry_root, run_id)
189
+ members = [name for name in ARCHIVE_MEMBER_NAMES if (run_path / name).exists()]
190
+ if not members:
191
+ return False
192
+ destination = archive_path(registry_root, run_id)
193
+ if destination.exists():
194
+ return _complete_archival_after_archive(
195
+ registry_root,
196
+ run_id,
197
+ destination=destination,
198
+ members=members,
199
+ )
200
+ expected_sizes = _expected_member_sizes(run_path, members)
201
+ run_registry.ensure_private_dir(archive_dir(registry_root))
202
+ temp_destination = destination.with_name(f"{destination.name}.tmp")
203
+ if temp_destination.exists():
204
+ temp_destination.unlink()
205
+ try:
206
+ with tarfile.open(temp_destination, "w:gz") as archive:
207
+ for name in members:
208
+ archive.add(run_path / name, arcname=name)
209
+ run_registry.ensure_private_file(temp_destination)
210
+ if not _verify_archive_members(temp_destination, expected_sizes):
211
+ return False
212
+ os.replace(temp_destination, destination)
213
+ run_registry.ensure_private_file(destination)
214
+ finally:
215
+ if temp_destination.exists():
216
+ temp_destination.unlink()
217
+ return _complete_archival_after_archive(
218
+ registry_root,
219
+ run_id,
220
+ destination=destination,
221
+ members=members,
222
+ )
223
+
224
+
225
+ def run_retention_pass(
226
+ registry_root: Path,
227
+ config: JsonObject,
228
+ *,
229
+ now: datetime | None = None,
230
+ ) -> dict[str, int]:
231
+ if not retention_enabled(config):
232
+ return {"scanned": 0, "archived": 0, "skipped": 0}
233
+ index = run_registry.load_index(registry_root)
234
+ scanned = 0
235
+ archived = 0
236
+ skipped = 0
237
+ with run_registry.registry_lock(registry_root):
238
+ for run_id in list(index.get("runs", {}).keys()):
239
+ if not isinstance(run_id, str):
240
+ continue
241
+ scanned += 1
242
+ if is_eligible_for_archival(registry_root, run_id, config=config, now=now):
243
+ if archive_run_raw_logs(registry_root, run_id):
244
+ archived += 1
245
+ else:
246
+ skipped += 1
247
+ else:
248
+ skipped += 1
249
+ return {"scanned": scanned, "archived": archived, "skipped": skipped}
250
+
251
+
252
+ def read_archived_member(archive_file: Path, member_name: str) -> str:
253
+ _validate_archive_member_name(member_name)
254
+ with tarfile.open(archive_file, "r:gz") as archive:
255
+ try:
256
+ member = archive.getmember(member_name)
257
+ except KeyError:
258
+ return ""
259
+ extracted = archive.extractfile(member)
260
+ if extracted is None:
261
+ return ""
262
+ return extracted.read().decode("utf-8", errors="replace")
263
+
264
+
265
+ def tail_archived_output(archive_file: Path, member_name: str, lines: int) -> LogOutput:
266
+ _validate_archive_member_name(member_name)
267
+ with tarfile.open(archive_file, "r:gz") as archive:
268
+ try:
269
+ extracted = archive.extractfile(member_name)
270
+ except KeyError:
271
+ return LogOutput(content="", truncated=False)
272
+ if extracted is None:
273
+ return LogOutput(content="", truncated=False)
274
+ return _tail_text_stream_output(extracted, lines)
275
+
276
+
277
+ def read_log_output(
278
+ registry_root: Path,
279
+ run_id: str,
280
+ log_name: str,
281
+ *,
282
+ tail: int | None,
283
+ raw: bool,
284
+ ) -> LogOutput:
285
+ _validate_archive_member_name(log_name)
286
+ run_path = run_registry.run_directory(registry_root, run_id)
287
+ live_path = run_path / log_name
288
+ if live_path.exists():
289
+ return log_output.read_log_output(live_path, tail=tail, raw=raw)
290
+ archive_file = archive_path(registry_root, run_id)
291
+ if not archive_file.exists():
292
+ return LogOutput(content="", truncated=False)
293
+ if raw:
294
+ content = read_archived_member(archive_file, log_name)
295
+ return LogOutput(content=content, truncated=False)
296
+ if tail is None:
297
+ raise ValueError("add --tail N or --raw to read stdout/stderr log output")
298
+ return tail_archived_output(archive_file, log_name, tail)
299
+
300
+
301
+ def log_file_byte_size(registry_root: Path, run_id: str, log_name: str) -> int:
302
+ _validate_archive_member_name(log_name)
303
+ run_path = run_registry.run_directory(registry_root, run_id)
304
+ live_path = run_path / log_name
305
+ if live_path.exists():
306
+ return live_path.stat().st_size
307
+ state = run_registry.load_run_state(registry_root, run_id)
308
+ if state is not None:
309
+ key = "stdoutBytes" if log_name == run_registry.STDOUT_LOG else "stderrBytes"
310
+ value = state.get(key)
311
+ if isinstance(value, int) and not isinstance(value, bool):
312
+ return value
313
+ archive_file = archive_path(registry_root, run_id)
314
+ try:
315
+ with tarfile.open(archive_file, "r:gz") as archive:
316
+ try:
317
+ return archive.getmember(log_name).size
318
+ except KeyError:
319
+ return 0
320
+ except FileNotFoundError:
321
+ return 0
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, TypeAlias
4
+
5
+ from delegate_agent.json_types import JsonObject
6
+
7
+ MetadataKeyGroup: TypeAlias = tuple[str, ...]
8
+
9
+ ISOLATION_METADATA_KEYS: MetadataKeyGroup = (
10
+ "isolatedWorkspace",
11
+ "isolationMode",
12
+ "effectiveIsolation",
13
+ "isolationLifecycle",
14
+ "preservedWorkspace",
15
+ )
16
+
17
+ PERSISTENT_WORKTREE_METADATA_KEYS: MetadataKeyGroup = (
18
+ "sourceGitRoot",
19
+ "branch",
20
+ "creationContext",
21
+ "worktreeStatus",
22
+ "safeWorkspaceMethod",
23
+ )
24
+
25
+ REASONING_METADATA_KEYS: MetadataKeyGroup = (
26
+ "requestedReasoningEffort",
27
+ "resolvedReasoningEffort",
28
+ "reasoningEffortSource",
29
+ "reasoningCapabilitySource",
30
+ "reasoningTransport",
31
+ )
32
+
33
+ SNAPSHOT_STATE_FALLBACK_KEYS: MetadataKeyGroup = (
34
+ "worktreeStatus",
35
+ "safeWorkspaceMethod",
36
+ )
37
+
38
+ SNAPSHOT_MANIFEST_FALLBACK_KEYS: MetadataKeyGroup = (
39
+ *ISOLATION_METADATA_KEYS[1:],
40
+ *PERSISTENT_WORKTREE_METADATA_KEYS,
41
+ "worktreeCleanupCommands",
42
+ *REASONING_METADATA_KEYS,
43
+ )
44
+
45
+
46
+ class RunMetadataCarrier(Protocol):
47
+ isolated_workspace: bool
48
+ isolation_mode: str
49
+ effective_isolation: str
50
+ isolation_lifecycle: str
51
+ preserved_workspace: bool
52
+ source_git_root: str | None
53
+ branch: str | None
54
+ creation_context: JsonObject | None
55
+ worktree_status: str | None
56
+ safe_workspace_method: str | None
57
+ warnings: tuple[str, ...]
58
+
59
+
60
+ def add_run_metadata_payload_fields(payload: JsonObject, carrier: RunMetadataCarrier) -> None:
61
+ payload["isolatedWorkspace"] = carrier.isolated_workspace
62
+ payload["isolationMode"] = carrier.isolation_mode
63
+ payload["effectiveIsolation"] = carrier.effective_isolation
64
+ payload["isolationLifecycle"] = carrier.isolation_lifecycle
65
+ payload["preservedWorkspace"] = carrier.preserved_workspace
66
+
67
+ if carrier.source_git_root is not None:
68
+ payload["sourceGitRoot"] = carrier.source_git_root
69
+ if carrier.branch is not None:
70
+ payload["branch"] = carrier.branch
71
+ if carrier.creation_context is not None:
72
+ payload["creationContext"] = carrier.creation_context
73
+ if carrier.worktree_status is not None:
74
+ payload["worktreeStatus"] = carrier.worktree_status
75
+ if carrier.safe_workspace_method is not None:
76
+ payload["safeWorkspaceMethod"] = carrier.safe_workspace_method
77
+ if carrier.warnings:
78
+ payload["warnings"] = list(carrier.warnings)