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,645 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ import tarfile
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import BinaryIO, TextIO
8
+
9
+ from delegate_agent import command_errors, harness_events, log_output, redaction, run_registry
10
+ from delegate_agent import rendering as delegate_rendering
11
+ from delegate_agent import retention as delegate_retention
12
+ from delegate_agent.json_types import JsonObject
13
+ from delegate_agent.log_output import RUN_OUTPUT_DEFAULT_MAX_CHARS
14
+
15
+ RECOVERY_STDOUT_TAIL_LINES = 2000
16
+ RECOVERY_STDOUT_TAIL_BYTES = 1_000_000
17
+ RUN_OUTPUT_DEFAULT_TAIL_LINES = 80
18
+ STREAM_READ_CHUNK_KIB = 64
19
+ STREAM_READ_CHUNK_BYTES = STREAM_READ_CHUNK_KIB * run_registry.BYTES_PER_KIB
20
+ RESULT_QUALITY_OK = harness_events.RESULT_QUALITY_OK
21
+ RESULT_QUALITY_HOUSEKEEPING = harness_events.RESULT_QUALITY_HOUSEKEEPING
22
+ RESULT_QUALITY_EMPTY = harness_events.RESULT_QUALITY_EMPTY
23
+ RESULT_QUALITY_SUSPECT_SHORT = harness_events.RESULT_QUALITY_SUSPECT_SHORT
24
+ RESULT_QUALITY_NO_ASSISTANT_TEXT = harness_events.RESULT_QUALITY_NO_ASSISTANT_TEXT
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RunOutputCommand:
29
+ handle: str | None
30
+ latest_harness: str | None = None
31
+ json_mode: bool = False
32
+ completion_report: bool = False
33
+ stdout: bool = False
34
+ stderr: bool = False
35
+ tail: int | None = None
36
+ max_chars: int | None = None
37
+ raw: bool = False
38
+ no_redact: bool = False
39
+ default: bool = False
40
+
41
+
42
+ class RunOutputError(command_errors.CommandError):
43
+ def __init__(
44
+ self,
45
+ error: str,
46
+ message: str,
47
+ *,
48
+ diagnostics: JsonObject | None = None,
49
+ next_actions: list[str] | None = None,
50
+ ) -> None:
51
+ super().__init__(error, message)
52
+ self.diagnostics = diagnostics
53
+ self.next_actions = next_actions
54
+
55
+
56
+ def _effective_max_chars(command: RunOutputCommand) -> int | None:
57
+ if command.raw:
58
+ return None
59
+ return command.max_chars if command.max_chars is not None else RUN_OUTPUT_DEFAULT_MAX_CHARS
60
+
61
+
62
+ def _add_log_output_section(
63
+ *,
64
+ registry_root: Path,
65
+ run_id: str,
66
+ log_name: str,
67
+ section_name: str,
68
+ tail: int | None,
69
+ raw: bool,
70
+ max_chars: int | None,
71
+ sections: JsonObject,
72
+ text_sections: dict[str, str],
73
+ ) -> None:
74
+ output = delegate_retention.read_log_output(
75
+ registry_root,
76
+ run_id,
77
+ log_name,
78
+ tail=tail,
79
+ raw=raw,
80
+ )
81
+ content = output.content
82
+ meta: JsonObject = {
83
+ "bytes": delegate_retention.log_file_byte_size(registry_root, run_id, log_name),
84
+ "truncated": output.truncated,
85
+ "archived": delegate_retention.raw_logs_archived(registry_root, run_id),
86
+ }
87
+ if raw:
88
+ meta["rawOutputBytes"] = meta["bytes"]
89
+ if tail is not None and not raw:
90
+ meta["tailLines"] = tail
91
+ if max_chars is not None:
92
+ capped = log_output.cap_content_by_chars(content, max_chars)
93
+ content = capped.content
94
+ meta["maxChars"] = max_chars
95
+ meta["charTruncated"] = capped.char_truncated
96
+ meta["returnedChars"] = capped.returned_chars
97
+ meta["omittedChars"] = capped.omitted_chars
98
+ sections[section_name] = meta
99
+ text_sections[section_name] = content
100
+
101
+
102
+ def _decode_recovery_tail(data: bytes, *, truncated: bool) -> tuple[str, bool]:
103
+ if truncated:
104
+ newline = data.find(b"\n")
105
+ if newline < 0:
106
+ return "", True
107
+ data = data[newline + 1 :]
108
+ text = data.decode("utf-8", errors="replace")
109
+ lines = text.splitlines()
110
+ if not lines:
111
+ return "", truncated
112
+ trimmed = lines[-RECOVERY_STDOUT_TAIL_LINES:]
113
+ return "\n".join(trimmed) + "\n", truncated or len(lines) > len(trimmed)
114
+
115
+
116
+ def _read_stream_tail_bytes(stream: BinaryIO, byte_limit: int) -> bytes:
117
+ buffer = bytearray()
118
+ while True:
119
+ chunk = stream.read(STREAM_READ_CHUNK_BYTES)
120
+ if not chunk:
121
+ break
122
+ buffer.extend(chunk)
123
+ if len(buffer) > byte_limit:
124
+ del buffer[: len(buffer) - byte_limit]
125
+ return bytes(buffer)
126
+
127
+
128
+ def _read_archived_recovery_stdout_tail(registry_root: Path, run_id: str) -> tuple[str, bool]:
129
+ archive_file = delegate_retention.archive_path(registry_root, run_id)
130
+ if not archive_file.exists():
131
+ return "", False
132
+ with tarfile.open(archive_file, "r:gz") as archive:
133
+ try:
134
+ member = archive.getmember(run_registry.STDOUT_LOG)
135
+ except KeyError:
136
+ return "", False
137
+ extracted = archive.extractfile(member)
138
+ if extracted is None:
139
+ return "", False
140
+ data = _read_stream_tail_bytes(extracted, RECOVERY_STDOUT_TAIL_BYTES)
141
+ return _decode_recovery_tail(data, truncated=member.size > len(data))
142
+
143
+
144
+ def _read_recovery_stdout_tail(registry_root: Path, run_id: str) -> tuple[str, bool]:
145
+ run_path = run_registry.run_directory(registry_root, run_id)
146
+ log_path = run_path / run_registry.STDOUT_LOG
147
+ if not log_path.exists():
148
+ return _read_archived_recovery_stdout_tail(registry_root, run_id)
149
+ with log_path.open("rb") as handle:
150
+ handle.seek(0, 2)
151
+ end = handle.tell()
152
+ if end == 0:
153
+ return "", False
154
+ start = max(0, end - RECOVERY_STDOUT_TAIL_BYTES)
155
+ handle.seek(start)
156
+ data = handle.read(end - start)
157
+ return _decode_recovery_tail(data, truncated=start > 0)
158
+
159
+
160
+ def _recover_completion_report_from_stdout(
161
+ registry_root: Path,
162
+ run_id: str,
163
+ *,
164
+ harness: str | None = None,
165
+ allow_last_assistant: bool = False,
166
+ ) -> tuple[str, bool, harness_events.RecoveryQuality | None]:
167
+ # The completion is the final turn's closing message, which lives at the end of
168
+ # the stream, so a bounded tail is sufficient and avoids loading a possibly
169
+ # huge stdout.log into memory on a routine run-output call. The byte bound is
170
+ # as important as the line bound because Codex JSONL can encode large command
171
+ # output inside a single physical line.
172
+ stdout_text, truncated = _read_recovery_stdout_tail(registry_root, run_id)
173
+ if not stdout_text:
174
+ return "", truncated, None
175
+ accumulator = harness_events.StreamAccumulator(harness=harness)
176
+ for line in stdout_text.split("\n"):
177
+ accumulator.ingest_line(line)
178
+ if accumulator.completion_text:
179
+ return accumulator.completion_text.strip(), truncated, "explicit_completion"
180
+ if allow_last_assistant and accumulator.recoverable_assistant_text:
181
+ quality = accumulator.assistant_recovery_quality()
182
+ return accumulator.recoverable_assistant_text.strip(), truncated, quality
183
+ return "", truncated, None
184
+
185
+
186
+ def _run_output_next_actions(handle: str) -> list[str]:
187
+ quoted = shlex.quote(handle)
188
+ return [
189
+ f"delegate run-output {quoted} --stdout --tail {RUN_OUTPUT_DEFAULT_TAIL_LINES}",
190
+ f"delegate run-output {quoted} --stderr --tail {RUN_OUTPUT_DEFAULT_TAIL_LINES}",
191
+ f"delegate run-output {quoted} --raw",
192
+ ]
193
+
194
+
195
+ def _completion_report_source(registry_root: Path, run_id: str) -> str | None:
196
+ state = run_registry.load_run_state(registry_root, run_id)
197
+ source = state.get("completionReportSource")
198
+ return source if isinstance(source, str) else None
199
+
200
+
201
+ def _completion_report_quality(
202
+ text: str,
203
+ *,
204
+ registry_root: Path,
205
+ run_id: str,
206
+ report_source: str | None = None,
207
+ ) -> str:
208
+ quality = harness_events.assistant_recovery_quality_for_text(text)
209
+ if quality == "housekeeping_fallback" and harness_events.is_housekeeping_assistant_text(text):
210
+ return RESULT_QUALITY_HOUSEKEEPING
211
+ manifest = run_registry.load_run_manifest(registry_root, run_id)
212
+ # suspect_short applies ONLY to genuine child reports that are short AND not
213
+ # substantive: a terse but substantive "Verdict:/Status:" report must NOT be
214
+ # flagged, while a preamble-only fragment must still flag. Delegate-synthesized
215
+ # and stdout-recovery reports are never suspect.
216
+ if (
217
+ manifest.get("mode") == "safe"
218
+ and report_source == "child"
219
+ and len(text.strip()) < 200
220
+ and not harness_events.is_substantive_assistant_text(text)
221
+ ):
222
+ return RESULT_QUALITY_SUSPECT_SHORT
223
+ return RESULT_QUALITY_OK
224
+
225
+
226
+ def _resolve_completion_report_quality(
227
+ text: str,
228
+ *,
229
+ registry_root: Path,
230
+ run_id: str,
231
+ report_source: str | None = None,
232
+ ) -> str:
233
+ # Read-time classification prefers the stored state.resultQuality the runner
234
+ # already computed at write time, and recomputes from disk only when state
235
+ # lacks it (e.g. older runs persisted before the field existed).
236
+ state = run_registry.load_run_state(registry_root, run_id)
237
+ stored = state.get("resultQuality") if isinstance(state, dict) else None
238
+ if isinstance(stored, str) and stored:
239
+ return stored
240
+ return _completion_report_quality(
241
+ text,
242
+ registry_root=registry_root,
243
+ run_id=run_id,
244
+ report_source=report_source,
245
+ )
246
+
247
+
248
+ # Delegate to the shared helper in harness_events so the runner (write-time) and
249
+ # run-output (read-time) channels emit identical warning text.
250
+ _quality_warning = harness_events.quality_warning
251
+
252
+
253
+ def _append_warning(warnings: list[str], warning: str | None) -> None:
254
+ if warning and warning not in warnings:
255
+ warnings.append(warning)
256
+
257
+
258
+ def _log_section_info(registry_root: Path, run_id: str, log_name: str) -> JsonObject:
259
+ """Probe a stream's presence and byte size once (live file first, then archive)."""
260
+ live_path = run_registry.run_directory(registry_root, run_id) / log_name
261
+ if live_path.exists():
262
+ return {"present": True, "bytes": live_path.stat().st_size}
263
+ size = delegate_retention.log_file_byte_size(registry_root, run_id, log_name)
264
+ return {"present": size > 0, "bytes": size}
265
+
266
+
267
+ def _run_output_diagnostics(
268
+ registry_root: Path,
269
+ run_id: str,
270
+ *,
271
+ recovery_attempted: bool = False,
272
+ recovery_truncated: bool = False,
273
+ recovery_quality: harness_events.RecoveryQuality | None = None,
274
+ ) -> JsonObject:
275
+ run_path = run_registry.run_directory(registry_root, run_id)
276
+ state = run_registry.load_run_state(registry_root, run_id)
277
+ report_path = run_path / run_registry.COMPLETION_REPORT_FILE
278
+ diagnostics: JsonObject = {
279
+ "status": run_registry.effective_status(state),
280
+ "rawLogsArchived": delegate_retention.raw_logs_archived(registry_root, run_id),
281
+ "completionReport": {
282
+ "present": report_path.exists(),
283
+ "bytes": report_path.stat().st_size if report_path.exists() else 0,
284
+ },
285
+ "stdout": _log_section_info(registry_root, run_id, run_registry.STDOUT_LOG),
286
+ "stderr": _log_section_info(registry_root, run_id, run_registry.STDERR_LOG),
287
+ }
288
+ if recovery_attempted:
289
+ recovery_meta: JsonObject = {
290
+ "attempted": True,
291
+ "source": run_registry.STDOUT_LOG,
292
+ "tailLines": RECOVERY_STDOUT_TAIL_LINES,
293
+ "tailBytes": RECOVERY_STDOUT_TAIL_BYTES,
294
+ "truncated": recovery_truncated,
295
+ }
296
+ if recovery_quality is not None:
297
+ recovery_meta["quality"] = recovery_quality
298
+ diagnostics["recovery"] = recovery_meta
299
+ return diagnostics
300
+
301
+
302
+ def _format_run_output_diagnostics(diagnostics: JsonObject, next_actions: list[str]) -> str:
303
+ stdout_info = diagnostics.get("stdout") if isinstance(diagnostics.get("stdout"), dict) else {}
304
+ stderr_info = diagnostics.get("stderr") if isinstance(diagnostics.get("stderr"), dict) else {}
305
+ recovery = diagnostics.get("recovery") if isinstance(diagnostics.get("recovery"), dict) else {}
306
+ lines = [
307
+ "No completion report or recoverable final message found.",
308
+ f"status: {diagnostics.get('status', 'unknown')}",
309
+ f"stdout: present={stdout_info.get('present', False)} bytes={stdout_info.get('bytes', 0)}",
310
+ f"stderr: present={stderr_info.get('present', False)} bytes={stderr_info.get('bytes', 0)}",
311
+ ]
312
+ if recovery:
313
+ lines.append(f"recovery: truncated={recovery.get('truncated', False)}")
314
+ if recovery.get("quality"):
315
+ lines.append(f"recovery quality: {recovery.get('quality')}")
316
+ lines.append("next actions:")
317
+ lines.extend(f" - {action}" for action in next_actions)
318
+ return "\n".join(lines) + "\n"
319
+
320
+
321
+ def _add_default_run_output_fallback(
322
+ *,
323
+ registry_root: Path,
324
+ run_id: str,
325
+ alias: str | None,
326
+ sections: JsonObject,
327
+ text_sections: dict[str, str],
328
+ recovery_attempted: bool,
329
+ recovery_truncated: bool,
330
+ recovery_quality: harness_events.RecoveryQuality | None = None,
331
+ ) -> None:
332
+ diagnostics = _run_output_diagnostics(
333
+ registry_root,
334
+ run_id,
335
+ recovery_attempted=recovery_attempted,
336
+ recovery_truncated=recovery_truncated,
337
+ recovery_quality=recovery_quality,
338
+ )
339
+ for log_name, section_name in (
340
+ (run_registry.STDOUT_LOG, "stdout"),
341
+ (run_registry.STDERR_LOG, "stderr"),
342
+ ):
343
+ stream_info = diagnostics.get(section_name)
344
+ if not (isinstance(stream_info, dict) and stream_info.get("present")):
345
+ continue
346
+ _add_log_output_section(
347
+ registry_root=registry_root,
348
+ run_id=run_id,
349
+ log_name=log_name,
350
+ section_name=section_name,
351
+ tail=RUN_OUTPUT_DEFAULT_TAIL_LINES,
352
+ raw=False,
353
+ max_chars=RUN_OUTPUT_DEFAULT_MAX_CHARS,
354
+ sections=sections,
355
+ text_sections=text_sections,
356
+ )
357
+ handle = alias or run_id
358
+ next_actions = _run_output_next_actions(handle)
359
+ diagnostics["nextActions"] = next_actions
360
+ content = _format_run_output_diagnostics(diagnostics, next_actions)
361
+ sections["diagnostics"] = {"bytes": len(content.encode("utf-8"))}
362
+ text_sections["diagnostics"] = content
363
+
364
+
365
+ def _add_completion_report_section(
366
+ command: RunOutputCommand,
367
+ *,
368
+ registry_root: Path,
369
+ run_id: str,
370
+ alias: str | None,
371
+ sections: JsonObject,
372
+ text_sections: dict[str, str],
373
+ warnings: list[str],
374
+ ) -> None:
375
+ if not command.completion_report:
376
+ return
377
+ run_path = run_registry.run_directory(registry_root, run_id)
378
+ report_path = run_path / run_registry.COMPLETION_REPORT_FILE
379
+ if report_path.exists():
380
+ text = report_path.read_text(encoding="utf-8", errors="replace")
381
+ manifest = run_registry.load_run_manifest(registry_root, run_id)
382
+ harness = manifest.get("harness") if isinstance(manifest, dict) else None
383
+ source = _completion_report_source(registry_root, run_id)
384
+ result_quality = _resolve_completion_report_quality(
385
+ text,
386
+ registry_root=registry_root,
387
+ run_id=run_id,
388
+ report_source=source,
389
+ )
390
+ _append_warning(
391
+ warnings,
392
+ _quality_warning(result_quality, harness=harness if isinstance(harness, str) else None),
393
+ )
394
+ meta: JsonObject = {
395
+ "bytes": len(text.encode("utf-8")),
396
+ "resultQuality": result_quality,
397
+ }
398
+ if source is not None:
399
+ meta["completionReportSource"] = source
400
+ sections["completionReport"] = meta
401
+ text_sections["completionReport"] = text
402
+ return
403
+
404
+ status = run_registry.effective_status(run_registry.load_run_state(registry_root, run_id))
405
+ text = ""
406
+ recovery_truncated = False
407
+ recovery_attempted = False
408
+ recovery_quality: harness_events.RecoveryQuality | None = None
409
+ if status != run_registry.STATUS_RUNNING:
410
+ manifest = run_registry.load_run_manifest(registry_root, run_id)
411
+ harness = manifest.get("harness") if isinstance(manifest, dict) else None
412
+ allow_last_assistant = harness in harness_events.ASSISTANT_RECOVERY_HARNESSES
413
+ recovery_attempted = True
414
+ text, recovery_truncated, recovery_quality = _recover_completion_report_from_stdout(
415
+ registry_root,
416
+ run_id,
417
+ harness=harness if isinstance(harness, str) else None,
418
+ allow_last_assistant=allow_last_assistant,
419
+ )
420
+ if text and recovery_quality != "housekeeping_fallback":
421
+ report_meta: JsonObject = {
422
+ "bytes": len(text.encode("utf-8")),
423
+ "source": run_registry.STDOUT_LOG,
424
+ "synthetic": True,
425
+ "tailLines": RECOVERY_STDOUT_TAIL_LINES,
426
+ "tailBytes": RECOVERY_STDOUT_TAIL_BYTES,
427
+ "truncated": recovery_truncated,
428
+ }
429
+ if recovery_quality is not None:
430
+ report_meta["recoveryQuality"] = recovery_quality
431
+ result_quality = (
432
+ RESULT_QUALITY_HOUSEKEEPING
433
+ if recovery_quality == "housekeeping_fallback"
434
+ else _resolve_completion_report_quality(
435
+ text,
436
+ registry_root=registry_root,
437
+ run_id=run_id,
438
+ report_source="stdout_recovery",
439
+ )
440
+ )
441
+ report_meta["resultQuality"] = result_quality
442
+ report_meta["completionReportSource"] = "stdout_recovery"
443
+ manifest = run_registry.load_run_manifest(registry_root, run_id)
444
+ harness = manifest.get("harness") if isinstance(manifest, dict) else None
445
+ _append_warning(
446
+ warnings,
447
+ _quality_warning(result_quality, harness=harness if isinstance(harness, str) else None),
448
+ )
449
+ sections["completionReport"] = report_meta
450
+ text_sections["completionReport"] = text
451
+ return
452
+
453
+ if command.default:
454
+ _add_default_run_output_fallback(
455
+ registry_root=registry_root,
456
+ run_id=run_id,
457
+ alias=alias,
458
+ sections=sections,
459
+ text_sections=text_sections,
460
+ recovery_attempted=recovery_attempted,
461
+ recovery_truncated=recovery_truncated,
462
+ recovery_quality=recovery_quality,
463
+ )
464
+ return
465
+
466
+ handle = alias or run_id
467
+ diagnostics = _run_output_diagnostics(
468
+ registry_root,
469
+ run_id,
470
+ recovery_attempted=recovery_attempted,
471
+ recovery_truncated=recovery_truncated,
472
+ recovery_quality=recovery_quality,
473
+ )
474
+ raise RunOutputError(
475
+ "missing_completion_report",
476
+ f"Completion report not found for run: {handle}",
477
+ diagnostics=diagnostics,
478
+ next_actions=_run_output_next_actions(handle),
479
+ )
480
+
481
+
482
+ def _add_default_fallback_if_requested(
483
+ command: RunOutputCommand,
484
+ *,
485
+ registry_root: Path,
486
+ run_id: str,
487
+ alias: str | None,
488
+ sections: JsonObject,
489
+ text_sections: dict[str, str],
490
+ ) -> None:
491
+ if not (command.default and not sections):
492
+ return
493
+ _add_default_run_output_fallback(
494
+ registry_root=registry_root,
495
+ run_id=run_id,
496
+ alias=alias,
497
+ sections=sections,
498
+ text_sections=text_sections,
499
+ recovery_attempted=False,
500
+ recovery_truncated=False,
501
+ )
502
+
503
+
504
+ def _redact_text_sections(
505
+ text_sections: dict[str, str],
506
+ *,
507
+ no_redact: bool,
508
+ ) -> dict[str, str]:
509
+ if no_redact:
510
+ return text_sections
511
+ return {key: redaction.redact_string(text) for key, text in text_sections.items()}
512
+
513
+
514
+ def _merge_json_sections(sections: JsonObject, text_sections: dict[str, str]) -> JsonObject:
515
+ merged_sections: JsonObject = {}
516
+ for key, meta in sections.items():
517
+ entry = dict(meta)
518
+ if key in text_sections:
519
+ entry["content"] = text_sections[key]
520
+ merged_sections[key] = entry
521
+ return merged_sections
522
+
523
+
524
+ def _emit_run_output_sections(
525
+ command: RunOutputCommand,
526
+ *,
527
+ alias: str | None,
528
+ run_id: str,
529
+ sections: JsonObject,
530
+ text_sections: dict[str, str],
531
+ resolution: JsonObject | None,
532
+ warnings: list[str],
533
+ stdout: TextIO,
534
+ ) -> None:
535
+ if command.json_mode:
536
+ payload = delegate_rendering.run_output_json_payload(
537
+ alias=alias,
538
+ run_id=run_id,
539
+ sections=_merge_json_sections(sections, text_sections),
540
+ resolution=resolution,
541
+ warnings=warnings,
542
+ )
543
+ delegate_rendering.print_json(payload, stdout)
544
+ return
545
+ delegate_rendering.render_run_output_text(
546
+ text_sections,
547
+ stdout,
548
+ section_meta=sections,
549
+ resolution=resolution,
550
+ warnings=warnings,
551
+ )
552
+
553
+
554
+ def emit(command: RunOutputCommand, *, workspace_path: str, stdout: TextIO) -> int:
555
+ workspace = Path(workspace_path)
556
+ registry_root = run_registry.registry_root_if_exists(workspace)
557
+ if registry_root is None:
558
+ registry_root = run_registry.registry_root(workspace)
559
+ target = run_registry.resolve_run_target(
560
+ registry_root,
561
+ handle=command.handle,
562
+ latest_harness=command.latest_harness,
563
+ )
564
+ if isinstance(target, run_registry.RunTargetLookupError):
565
+ raise RunOutputError(target.error, target.message)
566
+ run_id, alias = target.run_id, target.alias
567
+ resolution = (
568
+ {
569
+ "requestedHandle": target.requested_handle,
570
+ "resolvedHandle": target.resolved_handle or alias or run_id,
571
+ "resolutionKind": target.resolution_kind,
572
+ }
573
+ if target.resolution_kind != "literal"
574
+ else None
575
+ )
576
+ sections: JsonObject = {}
577
+ text_sections: dict[str, str] = {}
578
+ # Seed warnings from persisted state so re-emitted quality warnings dedupe
579
+ # against warnings the runner already recorded at write time (cross-channel
580
+ # duplicate suppression).
581
+ warnings: list[str] = []
582
+ state = run_registry.load_run_state(registry_root, run_id)
583
+ if isinstance(state, dict):
584
+ state_warnings = state.get("warnings")
585
+ if isinstance(state_warnings, list):
586
+ for warning in state_warnings:
587
+ if isinstance(warning, str) and warning not in warnings:
588
+ warnings.append(warning)
589
+ _add_completion_report_section(
590
+ command,
591
+ registry_root=registry_root,
592
+ run_id=run_id,
593
+ alias=alias,
594
+ sections=sections,
595
+ text_sections=text_sections,
596
+ warnings=warnings,
597
+ )
598
+ _add_default_fallback_if_requested(
599
+ command,
600
+ registry_root=registry_root,
601
+ run_id=run_id,
602
+ alias=alias,
603
+ sections=sections,
604
+ text_sections=text_sections,
605
+ )
606
+ max_chars = _effective_max_chars(command)
607
+ try:
608
+ if command.stdout or command.raw:
609
+ _add_log_output_section(
610
+ registry_root=registry_root,
611
+ run_id=run_id,
612
+ log_name=run_registry.STDOUT_LOG,
613
+ section_name="stdout",
614
+ tail=command.tail,
615
+ raw=command.raw,
616
+ max_chars=None if command.raw else max_chars,
617
+ sections=sections,
618
+ text_sections=text_sections,
619
+ )
620
+ if command.stderr or command.raw:
621
+ _add_log_output_section(
622
+ registry_root=registry_root,
623
+ run_id=run_id,
624
+ log_name=run_registry.STDERR_LOG,
625
+ section_name="stderr",
626
+ tail=command.tail,
627
+ raw=command.raw,
628
+ max_chars=None if command.raw else max_chars,
629
+ sections=sections,
630
+ text_sections=text_sections,
631
+ )
632
+ except ValueError as exc:
633
+ raise RunOutputError("missing_tail", str(exc)) from exc
634
+ text_sections = _redact_text_sections(text_sections, no_redact=command.no_redact)
635
+ _emit_run_output_sections(
636
+ command,
637
+ alias=alias,
638
+ run_id=run_id,
639
+ sections=sections,
640
+ text_sections=text_sections,
641
+ resolution=resolution,
642
+ warnings=warnings,
643
+ stdout=stdout,
644
+ )
645
+ return 0