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,1830 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ import math
6
+ import os
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field, replace
16
+ from pathlib import Path
17
+ from typing import BinaryIO, TextIO
18
+
19
+ from delegate_agent import config as delegate_config
20
+ from delegate_agent import (
21
+ harness_events,
22
+ profiles,
23
+ prompt_instructions,
24
+ reasoning,
25
+ redaction,
26
+ rendering,
27
+ run_metadata,
28
+ run_registry,
29
+ worktree_summary,
30
+ )
31
+ from delegate_agent.json_types import JsonObject
32
+
33
+ STDOUT_LOG = run_registry.STDOUT_LOG
34
+ STDERR_LOG = run_registry.STDERR_LOG
35
+ EVENTS_JSONL = run_registry.EVENTS_JSONL
36
+ MANIFEST_FILE = run_registry.MANIFEST_FILE
37
+ STATE_FILE = run_registry.STATE_FILE
38
+ SNAPSHOT_FILE = run_registry.SNAPSHOT_FILE
39
+ COMPLETION_REPORT_FILE = run_registry.COMPLETION_REPORT_FILE
40
+
41
+ PROGRESS_PERSIST_LINE_INTERVAL = 10
42
+ PROGRESS_PERSIST_TIME_INTERVAL_SEC = 0.5
43
+ DRAIN_JOIN_TIMEOUT_SEC = 5.0
44
+ MILLISECONDS_PER_SECOND = 1000
45
+ PROGRESS_INITIAL_DELAY_SEC = delegate_config.default_progress_initial_delay_sec()
46
+ PROGRESS_HEARTBEAT_INTERVAL_SEC = delegate_config.default_progress_interval_sec()
47
+ PROGRESS_INITIAL_DELAY_ENV = "DELEGATE_PROGRESS_INITIAL_DELAY_SEC"
48
+ PROGRESS_INTERVAL_ENV = "DELEGATE_PROGRESS_INTERVAL_SEC"
49
+ RESULT_QUALITY_OK = harness_events.RESULT_QUALITY_OK
50
+ RESULT_QUALITY_HOUSEKEEPING = harness_events.RESULT_QUALITY_HOUSEKEEPING
51
+ RESULT_QUALITY_EMPTY = harness_events.RESULT_QUALITY_EMPTY
52
+ RESULT_QUALITY_SUSPECT_SHORT = harness_events.RESULT_QUALITY_SUSPECT_SHORT
53
+ RESULT_QUALITY_NO_ASSISTANT_TEXT = harness_events.RESULT_QUALITY_NO_ASSISTANT_TEXT
54
+ COMPLETION_REPORT_SOURCE_CHILD = "child"
55
+ COMPLETION_REPORT_SOURCE_SYNTHESIZED = "delegate_synthesized"
56
+ COMPLETION_REPORT_SOURCE_STDOUT_RECOVERY = "stdout_recovery"
57
+ # Auth-failure detection matches against the REDACTED STDERR TAIL ONLY (not the
58
+ # normalized-events haystack), so a child report merely DISCUSSING a 401 in its
59
+ # events/report text cannot be misclassified as an auth failure. ``token_expired``
60
+ # and ``refresh token was revoked`` are unambiguous auth signals on their own; a
61
+ # bare ``401`` only counts when auth context appears nearby (unauthorized/token/
62
+ # auth within the same line), otherwise a 401 in unrelated output is ignored.
63
+ AUTH_FAILURE_PATTERNS = (
64
+ re.compile(r"401[^\n]{0,80}(?:unauthorized|token|auth)", re.IGNORECASE),
65
+ re.compile(r"token_expired", re.IGNORECASE),
66
+ re.compile(r"refresh token was revoked", re.IGNORECASE),
67
+ )
68
+
69
+ SKILL_REVIEW_PREFIX = prompt_instructions.SKILL_REVIEW_PREFIX
70
+ COMPLETION_REPORT_SUFFIX = prompt_instructions.COMPLETION_REPORT_SUFFIX
71
+ prepend_skill_review_instructions = prompt_instructions.prepend_skill_review_instructions
72
+ append_completion_report_instructions = prompt_instructions.append_completion_report_instructions
73
+
74
+
75
+ def _bounded_call_fallback_text(text: str) -> str:
76
+ if len(text) <= harness_events.ASSISTANT_TEXT_LIMIT:
77
+ return text
78
+ head = text[: harness_events.ASSISTANT_TEXT_HEAD]
79
+ tail = text[-harness_events.ASSISTANT_TEXT_TAIL :]
80
+ omitted = len(text) - harness_events.ASSISTANT_TEXT_HEAD - harness_events.ASSISTANT_TEXT_TAIL
81
+ return f"{head}\n\n… [{omitted} chars omitted] …\n\n{tail}"
82
+
83
+
84
+ class RunnerLaunchError(RuntimeError):
85
+ def __init__(self, error: str, message: str) -> None:
86
+ super().__init__(message)
87
+ self.error = error
88
+ self.message = message
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class RunContext:
93
+ registry_root: Path
94
+ run_id: str
95
+ alias: str
96
+ harness: str
97
+ engine: str
98
+ mode: str
99
+ model: str | None
100
+ source_cwd: str
101
+ execution_cwd: str
102
+ workspace_kind: str
103
+ isolated_workspace: bool
104
+ started_at: str
105
+ model_alias: str | None = None
106
+ model_resolved: str | None = None
107
+ creation_context: JsonObject | None = None
108
+ source_git_root: str | None = None
109
+ isolation_mode: str = "none"
110
+ effective_isolation: str = "none"
111
+ isolation_lifecycle: str = "none"
112
+ preserved_workspace: bool = False
113
+ branch: str | None = None
114
+ worktree_status: str | None = None
115
+ safe_workspace_method: str | None = None
116
+ warnings: tuple[str, ...] = ()
117
+ reasoning_effort: str | None = None
118
+ reasoning_effort_source: str | None = None
119
+ reasoning_capability_source: str | None = None
120
+ reasoning_transport: str | None = None
121
+ prompt_transport: str = "argv"
122
+ forbid_commit: bool = False
123
+ progress_initial_delay_sec: float = PROGRESS_INITIAL_DELAY_SEC
124
+ progress_interval_sec: float = PROGRESS_HEARTBEAT_INTERVAL_SEC
125
+ env_overrides: dict[str, str] = field(default_factory=dict)
126
+ fallback_env_overrides: dict[str, str] = field(default_factory=dict)
127
+ auth_profile: str | None = None
128
+ fallback_auth_profile: str | None = None
129
+ include_dirty: bool = False
130
+ synced_files: int = 0
131
+ group: str | None = None
132
+
133
+
134
+ def write_manifest(run_path: Path, manifest: JsonObject) -> None:
135
+ run_registry.write_json_atomic(run_path / MANIFEST_FILE, manifest)
136
+
137
+
138
+ def write_state(run_path: Path, state: JsonObject) -> None:
139
+ run_registry.write_json_atomic(run_path / STATE_FILE, state)
140
+
141
+
142
+ def write_snapshot(run_path: Path, snapshot: JsonObject) -> None:
143
+ run_registry.write_json_atomic(run_path / SNAPSHOT_FILE, snapshot)
144
+
145
+
146
+ def open_events_log(run_path: Path) -> TextIO:
147
+ run_registry.ensure_private_dir(run_path)
148
+ fd = os.open(
149
+ run_path / EVENTS_JSONL,
150
+ os.O_CREAT | os.O_APPEND | os.O_WRONLY,
151
+ run_registry.PRIVATE_FILE_MODE,
152
+ )
153
+ return os.fdopen(fd, "a", encoding="utf-8")
154
+
155
+
156
+ def append_event(handle: TextIO, event: JsonObject) -> None:
157
+ handle.write(json.dumps(event, sort_keys=True) + "\n")
158
+
159
+
160
+ def completion_report_path(run_id: str) -> str:
161
+ return f".delegate/runs/{run_id}/{COMPLETION_REPORT_FILE}"
162
+
163
+
164
+ def format_duration(duration_ms: int) -> str:
165
+ total_seconds = max(duration_ms // MILLISECONDS_PER_SECOND, 0)
166
+ minutes, seconds = divmod(total_seconds, 60)
167
+ hours, minutes = divmod(minutes, 60)
168
+ if hours:
169
+ return f"{hours}h{minutes}m{seconds}s"
170
+ if minutes:
171
+ return f"{minutes}m{seconds}s"
172
+ return f"{seconds}s"
173
+
174
+
175
+ def status_from_exit(exit_code: int) -> str:
176
+ return run_registry.STATUS_SUCCEEDED if exit_code == 0 else run_registry.STATUS_FAILED
177
+
178
+
179
+ def _terminal_override_extra(accumulator: harness_events.StreamAccumulator) -> JsonObject:
180
+ if accumulator.terminal_status not in {
181
+ run_registry.STATUS_FAILED,
182
+ run_registry.STATUS_CANCELLED,
183
+ }:
184
+ return {}
185
+ extra: JsonObject = {
186
+ "terminalStatus": accumulator.terminal_status,
187
+ "failureReason": "harness_cancelled"
188
+ if accumulator.terminal_status == run_registry.STATUS_CANCELLED
189
+ else "harness_error",
190
+ }
191
+ if accumulator.terminal_event is not None:
192
+ extra["terminalEvent"] = accumulator.terminal_event
193
+ return extra
194
+
195
+
196
+ def _merge_extra(payload: JsonObject, extra: JsonObject) -> None:
197
+ for key, value in extra.items():
198
+ if (
199
+ key == "warnings"
200
+ and isinstance(payload.get("warnings"), list)
201
+ and isinstance(value, list)
202
+ ):
203
+ merged: list[object] = []
204
+ seen: set[str] = set()
205
+ for warning in [*payload["warnings"], *value]:
206
+ if isinstance(warning, str):
207
+ if warning in seen:
208
+ continue
209
+ seen.add(warning)
210
+ merged.append(warning)
211
+ payload[key] = merged
212
+ else:
213
+ payload[key] = value
214
+
215
+
216
+ def build_manifest(ctx: RunContext, argv: list[str]) -> JsonObject:
217
+ payload: JsonObject = {
218
+ "schema": run_registry.MANIFEST_SCHEMA,
219
+ "runId": ctx.run_id,
220
+ "alias": ctx.alias,
221
+ "harness": ctx.harness,
222
+ "engine": ctx.engine,
223
+ "mode": ctx.mode,
224
+ "model": ctx.model,
225
+ "modelAlias": ctx.model_alias,
226
+ "modelResolved": ctx.model_resolved or ctx.model,
227
+ "cwd": ctx.source_cwd,
228
+ "executionCwd": ctx.execution_cwd,
229
+ "workspaceKind": ctx.workspace_kind,
230
+ "startedAt": ctx.started_at,
231
+ "argv": argv,
232
+ "promptTransport": ctx.prompt_transport,
233
+ }
234
+ run_metadata.add_run_metadata_payload_fields(payload, ctx)
235
+ reasoning.add_reasoning_payload_fields(payload, ctx)
236
+ if ctx.forbid_commit:
237
+ payload["commitPolicy"] = {"forbidCommit": True}
238
+ if ctx.auth_profile is not None:
239
+ payload["authProfile"] = ctx.auth_profile
240
+ if ctx.fallback_auth_profile is not None:
241
+ payload["fallbackProfile"] = ctx.fallback_auth_profile
242
+ if ctx.group is not None:
243
+ payload["group"] = ctx.group
244
+ if ctx.include_dirty:
245
+ payload["includeDirty"] = True
246
+ payload["syncedFiles"] = ctx.synced_files
247
+ return payload
248
+
249
+
250
+ def build_state(
251
+ ctx: RunContext,
252
+ *,
253
+ status: str,
254
+ exit_code: int | None = None,
255
+ stdout_bytes: int = 0,
256
+ stderr_bytes: int = 0,
257
+ current: str | None = None,
258
+ pid: int | None = None,
259
+ extra: JsonObject | None = None,
260
+ ) -> JsonObject:
261
+ now = run_registry.utc_now_iso()
262
+ state: JsonObject = {
263
+ "schema": run_registry.STATE_SCHEMA,
264
+ "runId": ctx.run_id,
265
+ "alias": ctx.alias,
266
+ "status": status,
267
+ "stdoutBytes": stdout_bytes,
268
+ "stderrBytes": stderr_bytes,
269
+ "lastActivityAt": now,
270
+ }
271
+ state["completionReportWritten"] = bool(
272
+ extra.get("completionReportWritten") if extra is not None else False
273
+ )
274
+ state["completionReportSource"] = (
275
+ extra.get("completionReportSource") if extra is not None else None
276
+ )
277
+ # Default to "ok" only when no extra payload is supplied (e.g. an early
278
+ # running persist). When extra is provided, respect its resultQuality
279
+ # explicitly: a None value means "no result to classify" (e.g. a launch
280
+ # failure that never ran the child), so the key is omitted entirely rather
281
+ # than defaulted to "ok".
282
+ if extra is None:
283
+ state["resultQuality"] = RESULT_QUALITY_OK
284
+ elif "resultQuality" in extra and extra["resultQuality"] is not None:
285
+ state["resultQuality"] = extra["resultQuality"]
286
+ if exit_code is not None:
287
+ state["exitCode"] = exit_code
288
+ state["finishedAt"] = now
289
+ if current:
290
+ state["current"] = current
291
+ if pid is not None:
292
+ state["pid"] = pid
293
+ with contextlib.suppress(OSError):
294
+ state["pgid"] = os.getpgid(pid)
295
+ if extra is not None:
296
+ state.update(extra)
297
+ if ctx.group is not None:
298
+ state["group"] = ctx.group
299
+ if ctx.include_dirty:
300
+ state["includeDirty"] = True
301
+ state["syncedFiles"] = ctx.synced_files
302
+ # A None resultQuality means "no result to classify" (e.g. a launch failure
303
+ # that never ran the child). Omit the key entirely rather than persist null,
304
+ # so launch-failure state stays consistent with its snapshot.
305
+ if state.get("resultQuality") is None:
306
+ state.pop("resultQuality", None)
307
+ return state
308
+
309
+
310
+ def _worktree_cleanup_commands(ctx: RunContext) -> JsonObject | None:
311
+ """Build the worktreeCleanupCommands object for persistent worktree runs.
312
+
313
+ Returns None if the run is not a persistent worktree run.
314
+ """
315
+ if ctx.isolation_lifecycle != "persistent" or ctx.branch is None:
316
+ return None
317
+ alias_str = ctx.alias
318
+ source_git = ctx.source_git_root or ""
319
+ exec_cwd = ctx.execution_cwd
320
+ branch = ctx.branch
321
+ remove_argv = ["git", "-C", source_git, "worktree", "remove", exec_cwd]
322
+ branch_argv = ["git", "-C", source_git, "branch", "-d", branch]
323
+ return {
324
+ "safe": f"delegate worktree remove {alias_str}",
325
+ "forceBranch": f"delegate worktree remove {alias_str} --force-branch",
326
+ "discardUncommitted": f"delegate worktree remove {alias_str} --discard-uncommitted",
327
+ "force": f"delegate worktree remove {alias_str} --force",
328
+ "rawGit": f"{shlex.join(remove_argv)} && {shlex.join(branch_argv)}",
329
+ }
330
+
331
+
332
+ def build_snapshot(
333
+ ctx: RunContext,
334
+ *,
335
+ accumulator: harness_events.StreamAccumulator,
336
+ exit_code: int | None = None,
337
+ completion_report_written: bool = False,
338
+ extra: JsonObject | None = None,
339
+ ) -> JsonObject:
340
+ _assistant_text, assistant_meta = accumulator.bounded_assistant_text()
341
+ recent_events, events_meta = accumulator.bounded_recent_events()
342
+ snapshot: JsonObject = {
343
+ "schema": run_registry.SNAPSHOT_SCHEMA,
344
+ "ok": True,
345
+ "alias": ctx.alias,
346
+ "runId": ctx.run_id,
347
+ "harness": ctx.harness,
348
+ "cwd": ctx.source_cwd,
349
+ "executionCwd": ctx.execution_cwd,
350
+ "mode": ctx.mode,
351
+ "model": ctx.model,
352
+ "startedAt": ctx.started_at,
353
+ "current": accumulator.current,
354
+ "recentEvents": recent_events,
355
+ "completionReportWritten": completion_report_written,
356
+ "completionReportSource": None,
357
+ "resultQuality": RESULT_QUALITY_OK,
358
+ **assistant_meta,
359
+ **events_meta,
360
+ }
361
+ run_metadata.add_run_metadata_payload_fields(snapshot, ctx)
362
+ reasoning.add_reasoning_payload_fields(snapshot, ctx)
363
+
364
+ # Worktree cleanup commands for persistent worktrees.
365
+ cleanup = _worktree_cleanup_commands(ctx)
366
+ if cleanup is not None:
367
+ snapshot["worktreeCleanupCommands"] = cleanup
368
+
369
+ if exit_code is not None:
370
+ snapshot["exitCode"] = exit_code
371
+ if accumulator.terminal_event is not None:
372
+ snapshot["terminalEvent"] = accumulator.terminal_event
373
+ if accumulator.terminal_status is not None:
374
+ snapshot["terminalStatus"] = accumulator.terminal_status
375
+ if ctx.group is not None:
376
+ snapshot["group"] = ctx.group
377
+ if ctx.include_dirty:
378
+ snapshot["includeDirty"] = True
379
+ snapshot["syncedFiles"] = ctx.synced_files
380
+ if completion_report_written:
381
+ report_path = completion_report_path(ctx.run_id)
382
+ snapshot["completionReport"] = {
383
+ "path": report_path,
384
+ "command": run_registry.run_output_command(
385
+ ctx.alias,
386
+ completion_report=True,
387
+ cwd=ctx.source_cwd,
388
+ ),
389
+ }
390
+ if extra is not None:
391
+ _merge_extra(snapshot, extra)
392
+ return snapshot
393
+
394
+
395
+ def persist_progress(
396
+ run_path: Path,
397
+ ctx: RunContext,
398
+ accumulator: harness_events.StreamAccumulator,
399
+ *,
400
+ status: str,
401
+ exit_code: int | None = None,
402
+ stdout_bytes: int = 0,
403
+ stderr_bytes: int = 0,
404
+ pid: int | None = None,
405
+ completion_report_written: bool = False,
406
+ extra: JsonObject | None = None,
407
+ ) -> None:
408
+ write_state(
409
+ run_path,
410
+ build_state(
411
+ ctx,
412
+ status=status,
413
+ exit_code=exit_code,
414
+ stdout_bytes=stdout_bytes,
415
+ stderr_bytes=stderr_bytes,
416
+ current=accumulator.current,
417
+ pid=pid,
418
+ extra=extra,
419
+ ),
420
+ )
421
+ write_snapshot(
422
+ run_path,
423
+ build_snapshot(
424
+ ctx,
425
+ accumulator=accumulator,
426
+ exit_code=exit_code,
427
+ completion_report_written=completion_report_written,
428
+ extra=extra,
429
+ ),
430
+ )
431
+
432
+
433
+ def _persist_final_progress(
434
+ run_path: Path,
435
+ ctx: RunContext,
436
+ accumulator: harness_events.StreamAccumulator,
437
+ *,
438
+ status: str,
439
+ exit_code: int,
440
+ stdout_bytes: int,
441
+ stderr_bytes: int,
442
+ completion_report_written: bool,
443
+ extra: JsonObject,
444
+ ) -> str:
445
+ """Persist terminal state with cancel-precedence reconciliation.
446
+
447
+ Acquires the registry lock, re-reads the current persisted state, and if a
448
+ concurrent ``cancel`` already wrote ``cancelled`` (or stamped the
449
+ ``cancelRequested`` marker before signaling), preserves/adopts cancelled
450
+ status and the ``cancelled_by_user`` failure reason instead of downgrading
451
+ to the runner's exit-code-derived status. The runner's work summary/output
452
+ metadata (exit_code, byte counts, completion report, result quality, etc.)
453
+ is still recorded. Returns the status that was actually persisted.
454
+
455
+ The ``cancelRequested`` marker handles the finalize-first race: cancel
456
+ stamps the marker under the lock BEFORE sending SIGTERM, so if the child
457
+ exits 0 on SIGTERM and the runner finalizes before cancel's post-grace
458
+ terminal write, the finalizer still observes the marker and persists
459
+ cancelled, keeping the live envelope (ok/status/exitCode) consistent with
460
+ the eventual reconciled state.
461
+ """
462
+ persisted_status = status
463
+ persisted_extra = dict(extra)
464
+ with run_registry.registry_lock(ctx.registry_root):
465
+ current = run_registry.load_run_state_or_none(ctx.registry_root, ctx.run_id)
466
+ current_status = current.get("status") if isinstance(current, dict) else None
467
+ cancel_requested = isinstance(current, dict) and current.get("cancelRequested") is True
468
+ if current_status == run_registry.STATUS_CANCELLED or cancel_requested:
469
+ # Cancel won the race (either it already wrote cancelled, or it
470
+ # stamped the cancelRequested marker before signaling and the child
471
+ # exited before cancel's post-grace terminal write). Do not
472
+ # downgrade. Persist cancelled status and the cancel failure reason,
473
+ # but still record the runner's work summary/output metadata.
474
+ persisted_status = run_registry.STATUS_CANCELLED
475
+ persisted_extra["failureReason"] = "cancelled_by_user"
476
+ if exit_code == 0:
477
+ # A cancelled run never reports success.
478
+ persisted_extra["exitCode"] = 1
479
+ else:
480
+ persisted_extra["exitCode"] = exit_code
481
+ write_state(
482
+ run_path,
483
+ build_state(
484
+ ctx,
485
+ status=persisted_status,
486
+ exit_code=exit_code,
487
+ stdout_bytes=stdout_bytes,
488
+ stderr_bytes=stderr_bytes,
489
+ current=accumulator.current,
490
+ pid=persisted_extra.get("pid"),
491
+ extra=persisted_extra,
492
+ ),
493
+ )
494
+ write_snapshot(
495
+ run_path,
496
+ build_snapshot(
497
+ ctx,
498
+ accumulator=accumulator,
499
+ exit_code=exit_code,
500
+ completion_report_written=completion_report_written,
501
+ extra=persisted_extra,
502
+ ),
503
+ )
504
+ return persisted_status
505
+
506
+
507
+ def write_completion_report(run_path: Path, text: str) -> bool:
508
+ cleaned = text.strip()
509
+ if not cleaned:
510
+ return False
511
+ path = run_path / COMPLETION_REPORT_FILE
512
+ run_registry.write_private_text(path, cleaned + "\n")
513
+ return True
514
+
515
+
516
+ def _append_unique(values: list[str], value: str) -> None:
517
+ if value not in values:
518
+ values.append(value)
519
+
520
+
521
+ # Delegate to the shared helper in harness_events so the runner (write-time) and
522
+ # run-output (read-time) channels emit identical warning text.
523
+ _quality_warning = harness_events.quality_warning
524
+
525
+
526
+ def _classify_result_quality(
527
+ *,
528
+ ctx: RunContext,
529
+ exit_code: int,
530
+ report_text: str,
531
+ report_written: bool,
532
+ report_source: str | None,
533
+ accumulator: harness_events.StreamAccumulator,
534
+ ) -> str:
535
+ if report_text.strip():
536
+ quality = harness_events.assistant_recovery_quality_for_text(report_text)
537
+ if quality == "housekeeping_fallback" and harness_events.is_housekeeping_assistant_text(
538
+ report_text
539
+ ):
540
+ return RESULT_QUALITY_HOUSEKEEPING
541
+ # suspect_short applies ONLY to genuine child reports that are short AND
542
+ # not substantive: a terse but substantive "Verdict:/Status:" report must
543
+ # NOT be flagged, while a preamble-only fragment like "Performing an
544
+ # adversarial review..." must still flag. Delegate-synthesized reports
545
+ # are never suspect (they are structured diagnostics, not child output).
546
+ if (
547
+ ctx.mode == "safe"
548
+ and report_source == COMPLETION_REPORT_SOURCE_CHILD
549
+ and len(report_text.strip()) < 200
550
+ and not harness_events.is_substantive_assistant_text(report_text)
551
+ ):
552
+ return RESULT_QUALITY_SUSPECT_SHORT
553
+ return RESULT_QUALITY_OK
554
+ if (
555
+ exit_code == 0
556
+ and accumulator.structured_events_seen > 0
557
+ and not accumulator.assistant_text.strip()
558
+ and not accumulator.completion_text
559
+ ):
560
+ return RESULT_QUALITY_NO_ASSISTANT_TEXT
561
+ if exit_code == 0 and not report_written:
562
+ return RESULT_QUALITY_EMPTY
563
+ return RESULT_QUALITY_OK
564
+
565
+
566
+ def _redacted_tail_from_bytes(data: bytes, *, limit: int = profiles.STDERR_TAIL_LIMIT) -> str:
567
+ if limit <= 0:
568
+ return ""
569
+ return redaction.redact_string(data[-limit:].decode("utf-8", errors="replace"))
570
+
571
+
572
+ def _auth_failure_seen(stderr_tail: str, accumulator: harness_events.StreamAccumulator) -> bool:
573
+ # Match against the redacted stderr tail ONLY. Inspecting the normalized
574
+ # events haystack would misclassify a child report that merely DISCUSSES a
575
+ # 401 (e.g. a review mentioning "the endpoint returned 401") as an auth
576
+ # failure. The accumulator is accepted to keep the call site stable but is
577
+ # intentionally not consulted here.
578
+ del accumulator # unused: auth signals come from stderr, not event text
579
+ return any(pattern.search(stderr_tail) for pattern in AUTH_FAILURE_PATTERNS)
580
+
581
+
582
+ def _failure_reason(
583
+ *,
584
+ status: str,
585
+ stderr_tail: str,
586
+ accumulator: harness_events.StreamAccumulator,
587
+ extra: JsonObject,
588
+ ) -> str | None:
589
+ existing = extra.get("failureReason")
590
+ if isinstance(existing, str) and existing:
591
+ return existing
592
+ if status not in {run_registry.STATUS_FAILED, run_registry.STATUS_CANCELLED}:
593
+ return None
594
+ if _auth_failure_seen(stderr_tail, accumulator):
595
+ return "auth_failed"
596
+ if status == run_registry.STATUS_CANCELLED:
597
+ return "harness_cancelled"
598
+ return "child_failed"
599
+
600
+
601
+ def _auth_remediation_actions(ctx: RunContext) -> list[str]:
602
+ """Harness-specific auth-remediation next-actions for an auth_failed run."""
603
+ if ctx.harness == "codex" or ctx.engine == "codex":
604
+ return ["delegate profiles", "codex login"]
605
+ return [f"re-authenticate the {ctx.harness} CLI"]
606
+
607
+
608
+ def _auth_remediation_line(ctx: RunContext) -> str:
609
+ """Harness-specific auth-remediation prose for a synthesized report."""
610
+ if ctx.harness == "codex" or ctx.engine == "codex":
611
+ return (
612
+ "Remediation: inspect `delegate profiles`, then refresh Codex auth with `codex login`."
613
+ )
614
+ return f"Remediation: re-authenticate the {ctx.harness} CLI."
615
+
616
+
617
+ def _completion_report_text_and_source(
618
+ ctx: RunContext,
619
+ accumulator: harness_events.StreamAccumulator,
620
+ *,
621
+ completion_report_mode: str,
622
+ status: str,
623
+ failure_reason: str | None,
624
+ stderr_tail: str,
625
+ ) -> tuple[str, str | None]:
626
+ child_text = _completion_report_source(
627
+ ctx,
628
+ accumulator,
629
+ completion_report_mode=completion_report_mode,
630
+ ).strip()
631
+ if accumulator.completion_text and child_text:
632
+ return child_text, COMPLETION_REPORT_SOURCE_CHILD
633
+ if status == run_registry.STATUS_FAILED and not accumulator.completion_text:
634
+ next_actions = [
635
+ run_registry.run_output_command(
636
+ ctx.alias,
637
+ cwd=ctx.source_cwd,
638
+ )
639
+ + " --stderr --tail 80",
640
+ ]
641
+ if failure_reason == "auth_failed":
642
+ next_actions = [*_auth_remediation_actions(ctx), *next_actions]
643
+ lines = [
644
+ "Synthesized by delegate.",
645
+ "",
646
+ f"Status: {status}",
647
+ f"Failure reason: {failure_reason or 'child_failed'}",
648
+ ]
649
+ if failure_reason == "auth_failed":
650
+ lines.append(_auth_remediation_line(ctx))
651
+ if stderr_tail.strip():
652
+ lines.extend(["", "Redacted stderr tail:", "```text", stderr_tail.rstrip(), "```"])
653
+ lines.extend(["", "Next actions:", *(f"- {action}" for action in next_actions)])
654
+ return "\n".join(lines), COMPLETION_REPORT_SOURCE_SYNTHESIZED
655
+ if status == run_registry.STATUS_CANCELLED:
656
+ # A cancelled run was killed mid-flight: a partial child message left in
657
+ # stdout is NOT a valid completion report, so synthesize one regardless of
658
+ # any recoverable assistant text. The failure reason is the cancel reason
659
+ # already computed by _failure_reason (cancelled_by_user when cancel
660
+ # requested/won the race, harness_cancelled when a harness terminal event
661
+ # drove the cancellation). Same envelope fields as the failed path.
662
+ reason = (
663
+ failure_reason
664
+ if failure_reason
665
+ in {
666
+ "cancelled_by_user",
667
+ "harness_cancelled",
668
+ }
669
+ else "cancelled_by_user"
670
+ )
671
+ next_actions = [
672
+ run_registry.run_output_command(
673
+ ctx.alias,
674
+ cwd=ctx.source_cwd,
675
+ )
676
+ + " for partial output",
677
+ ]
678
+ lines = [
679
+ "Synthesized by delegate.",
680
+ "",
681
+ f"Status: {status}",
682
+ f"Failure reason: {reason}",
683
+ ]
684
+ if stderr_tail.strip():
685
+ lines.extend(["", "Redacted stderr tail:", "```text", stderr_tail.rstrip(), "```"])
686
+ lines.extend(["", "Next actions:", *(f"- {action}" for action in next_actions)])
687
+ return "\n".join(lines), COMPLETION_REPORT_SOURCE_SYNTHESIZED
688
+ if child_text:
689
+ return child_text, COMPLETION_REPORT_SOURCE_STDOUT_RECOVERY
690
+ return "", None
691
+
692
+
693
+ def emit_bounded_text_summary(
694
+ ctx: RunContext,
695
+ *,
696
+ status: str,
697
+ duration_ms: int,
698
+ stdout: TextIO,
699
+ extra: JsonObject | None = None,
700
+ ) -> None:
701
+ harness_label = ctx.harness
702
+ print(
703
+ f"delegate run {harness_label} completed in {format_duration(duration_ms)}",
704
+ file=stdout,
705
+ )
706
+ print(f"alias: {ctx.alias}", file=stdout)
707
+ print(f"status: {status}", file=stdout)
708
+ print(f"source: {ctx.source_cwd}", file=stdout)
709
+ print(f"execution: {ctx.execution_cwd}", file=stdout)
710
+ if ctx.branch:
711
+ print(f"branch: {ctx.branch}", file=stdout)
712
+ lifecycle = ctx.isolation_lifecycle
713
+ if lifecycle == "persistent":
714
+ print("isolation: worktree persistent", file=stdout)
715
+ elif lifecycle == "temporary":
716
+ print("isolation: worktree temporary", file=stdout)
717
+ else:
718
+ print(f"isolation: {lifecycle}", file=stdout)
719
+ if ctx.safe_workspace_method:
720
+ print(f"safe workspace method: {ctx.safe_workspace_method}", file=stdout)
721
+ if ctx.include_dirty:
722
+ print(f"syncedFiles: {ctx.synced_files}", file=stdout)
723
+ for warning in ctx.warnings:
724
+ print(f"warning: {warning}", file=stdout)
725
+ if extra is not None:
726
+ extra_warnings = extra.get("warnings")
727
+ if isinstance(extra_warnings, list):
728
+ for warning in extra_warnings:
729
+ if isinstance(warning, str) and warning not in ctx.warnings:
730
+ print(f"warning: {warning}", file=stdout)
731
+ work_summary = extra.get("workSummary")
732
+ if isinstance(work_summary, dict):
733
+ commits_created = work_summary.get("commitsCreatedCount", 0)
734
+ print(
735
+ "work summary: "
736
+ f"{work_summary.get('changedFilesCount', 0)} changed files, "
737
+ f"{commits_created} commits",
738
+ file=stdout,
739
+ )
740
+ if commits_created:
741
+ print(
742
+ "warning: child created commits; review them before integration",
743
+ file=stdout,
744
+ )
745
+ if work_summary.get("noChanges") is True:
746
+ print("work summary: no file changes or commits detected", file=stdout)
747
+ if extra.get("commitPolicyViolated") is True:
748
+ print("commit policy: violated (--forbid-commit)", file=stdout)
749
+ if extra.get("commitPolicyUnverified") is True:
750
+ print("commit policy: unverified (--forbid-commit)", file=stdout)
751
+ print(f"snapshot: {run_registry.snapshot_command(ctx.alias, cwd=ctx.source_cwd)}", file=stdout)
752
+ report_written = extra.get("completionReportWritten") if isinstance(extra, dict) else False
753
+ report_source = extra.get("completionReportSource") if isinstance(extra, dict) else None
754
+ if report_written:
755
+ print(
756
+ "completion report: "
757
+ f"{run_registry.run_output_command(ctx.alias, completion_report=True, cwd=ctx.source_cwd)}",
758
+ file=stdout,
759
+ )
760
+ if isinstance(report_source, str):
761
+ print(f"completion report source: {report_source}", file=stdout)
762
+ else:
763
+ print(
764
+ f"diagnostics: {run_registry.run_output_command(ctx.alias, cwd=ctx.source_cwd)}",
765
+ file=stdout,
766
+ )
767
+ if ctx.execution_cwd and (lifecycle == "temporary" or lifecycle == "persistent"):
768
+ print(
769
+ f"inspect: {shlex.join(['git', '-C', ctx.execution_cwd, 'status', '--short'])}",
770
+ file=stdout,
771
+ )
772
+ print(
773
+ f"review diff: {shlex.join(['git', '-C', ctx.execution_cwd, 'diff', '--stat', 'HEAD'])}",
774
+ file=stdout,
775
+ )
776
+ if lifecycle == "persistent" and ctx.branch and ctx.source_git_root:
777
+ cleanup = _worktree_cleanup_commands(ctx)
778
+ if cleanup is not None:
779
+ rendering.render_worktree_cleanup_commands(cleanup, stdout)
780
+
781
+
782
+ def completion_json_payload(
783
+ ctx: RunContext,
784
+ *,
785
+ ok: bool,
786
+ status: str,
787
+ exit_code: int,
788
+ duration_ms: int,
789
+ stdout_bytes: int,
790
+ stderr_bytes: int,
791
+ completion_report_written: bool = False,
792
+ assistant_meta: JsonObject | None = None,
793
+ extra: JsonObject | None = None,
794
+ ) -> JsonObject:
795
+ payload: JsonObject = {
796
+ "ok": ok,
797
+ "exitCode": exit_code,
798
+ "alias": ctx.alias,
799
+ "runId": ctx.run_id,
800
+ "status": status,
801
+ "engine": ctx.engine,
802
+ "mode": ctx.mode,
803
+ "model": ctx.model,
804
+ "modelAlias": ctx.model_alias,
805
+ "modelResolved": ctx.model_resolved or ctx.model,
806
+ "cwd": ctx.source_cwd,
807
+ "executionCwd": ctx.execution_cwd,
808
+ "workspaceKind": ctx.workspace_kind,
809
+ "durationMs": duration_ms,
810
+ "snapshotCommand": run_registry.snapshot_command(ctx.alias, cwd=ctx.source_cwd),
811
+ "stdoutBytes": stdout_bytes,
812
+ "stderrBytes": stderr_bytes,
813
+ }
814
+ if completion_report_written:
815
+ payload["completionReportCommand"] = run_registry.run_output_command(
816
+ ctx.alias,
817
+ completion_report=True,
818
+ cwd=ctx.source_cwd,
819
+ )
820
+ payload["completionReportPath"] = completion_report_path(ctx.run_id)
821
+ payload["completionReportWritten"] = completion_report_written
822
+ payload["completionReportSource"] = (
823
+ extra.get("completionReportSource") if extra is not None else None
824
+ )
825
+ payload["resultQuality"] = (
826
+ extra.get("resultQuality") if extra is not None else RESULT_QUALITY_OK
827
+ )
828
+ if ctx.auth_profile is not None:
829
+ payload["authProfile"] = ctx.auth_profile
830
+ if ctx.fallback_auth_profile is not None:
831
+ payload["fallbackProfile"] = ctx.fallback_auth_profile
832
+ if ctx.group is not None:
833
+ payload["group"] = ctx.group
834
+ if ctx.include_dirty:
835
+ payload["includeDirty"] = True
836
+ payload["syncedFiles"] = ctx.synced_files
837
+ run_metadata.add_run_metadata_payload_fields(payload, ctx)
838
+ reasoning.add_reasoning_payload_fields(payload, ctx)
839
+ if assistant_meta is not None:
840
+ payload.update(assistant_meta)
841
+
842
+ # Worktree cleanup commands for persistent worktrees.
843
+ cleanup = _worktree_cleanup_commands(ctx)
844
+ if cleanup is not None:
845
+ payload["worktreeCleanupCommands"] = cleanup
846
+ if extra is not None:
847
+ _merge_extra(payload, extra)
848
+
849
+ if not ok:
850
+ if extra is not None and extra.get("commitPolicyCausedFailure") is True:
851
+ payload["error"] = str(extra.get("error") or "commit_policy_violated")
852
+ payload["message"] = str(extra.get("message") or "Commit policy failed.")
853
+ else:
854
+ payload["error"] = "child_failed"
855
+ payload["message"] = "Child command failed."
856
+ return payload
857
+
858
+
859
+ def _drain_stream(
860
+ pipe: BinaryIO,
861
+ log_path: Path,
862
+ byte_counter: ByteCounter,
863
+ *,
864
+ on_line: Callable[[str], None] | None,
865
+ ) -> None:
866
+ with log_path.open("ab") as log_handle:
867
+ while True:
868
+ chunk = pipe.readline()
869
+ if not chunk:
870
+ break
871
+ byte_counter.total += len(chunk)
872
+ log_handle.write(chunk)
873
+ if on_line is not None:
874
+ on_line(chunk.decode("utf-8", errors="replace"))
875
+
876
+
877
+ def _join_drain_thread(thread: threading.Thread, pipe: BinaryIO | None) -> None:
878
+ thread.join(timeout=DRAIN_JOIN_TIMEOUT_SEC)
879
+ if thread.is_alive() and pipe is not None:
880
+ with contextlib.suppress(OSError):
881
+ pipe.close()
882
+ thread.join(timeout=1.0)
883
+
884
+
885
+ def _write_stdin(pipe: BinaryIO | None, stdin_text: str, failures: list[str]) -> None:
886
+ if pipe is None:
887
+ return
888
+ try:
889
+ pipe.write(stdin_text.encode("utf-8"))
890
+ pipe.flush()
891
+ except (BrokenPipeError, OSError) as exc:
892
+ # The child may have exited or closed stdin before reading the prompt.
893
+ # Record it so the run can report possibly-undelivered prompt text
894
+ # instead of silently proceeding as if delivery succeeded.
895
+ failures.append(f"stdin prompt delivery may have failed: {exc}")
896
+ finally:
897
+ with contextlib.suppress(OSError):
898
+ pipe.close()
899
+
900
+
901
+ def _join_stdin_thread(thread: threading.Thread | None, pipe: BinaryIO | None) -> None:
902
+ if thread is not None:
903
+ _join_drain_thread(thread, pipe)
904
+
905
+
906
+ def _materialize_prompt_file_argv(
907
+ argv: list[str],
908
+ *,
909
+ prompt_file_text: str | None,
910
+ prompt_file_placeholder: str | None,
911
+ ) -> tuple[list[str], Path | None]:
912
+ if prompt_file_text is None:
913
+ return list(argv), None
914
+ if prompt_file_placeholder is None or prompt_file_placeholder not in argv:
915
+ raise ValueError("prompt_file_placeholder must be present in argv")
916
+ temp_dir = Path(tempfile.mkdtemp(prefix="delegate-prompt-"))
917
+ os.chmod(temp_dir, run_registry.PRIVATE_DIR_MODE)
918
+ prompt_path = temp_dir / "prompt.txt"
919
+ fd = os.open(
920
+ prompt_path,
921
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
922
+ run_registry.PRIVATE_FILE_MODE,
923
+ )
924
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
925
+ handle.write(prompt_file_text)
926
+ return [
927
+ str(prompt_path) if item == prompt_file_placeholder else item for item in argv
928
+ ], temp_dir
929
+
930
+
931
+ def _cleanup_prompt_file_dir(temp_dir: Path | None) -> None:
932
+ if temp_dir is not None:
933
+ shutil.rmtree(temp_dir, ignore_errors=True)
934
+
935
+
936
+ @dataclass(frozen=True)
937
+ class TrackedRunFiles:
938
+ run_path: Path
939
+ stdout_log: Path
940
+ stderr_log: Path
941
+ scratch_dir: Path | None = None
942
+
943
+
944
+ @dataclass(frozen=True)
945
+ class TrackedCaptureResult:
946
+ accumulator: harness_events.StreamAccumulator
947
+ exit_code: int
948
+ duration_ms: int
949
+ stdout_bytes: int
950
+ stderr_bytes: int
951
+ stdin_failures: tuple[str, ...]
952
+ pid: int
953
+ pgid: int | None
954
+
955
+
956
+ @dataclass(frozen=True)
957
+ class CallResult:
958
+ text: str
959
+ exit_code: int
960
+ duration_ms: int
961
+ stdout_bytes: int
962
+ stderr_bytes: int
963
+ text_chars: int
964
+ text_truncated: bool
965
+ stderr_tail: str = ""
966
+ warnings: tuple[str, ...] = ()
967
+
968
+
969
+ @dataclass(frozen=True)
970
+ class TrackedFinalization:
971
+ status: str
972
+ exit_code: int
973
+ report_written: bool
974
+ extra: JsonObject
975
+
976
+
977
+ def _persistent_work_summary(ctx: RunContext) -> JsonObject | None:
978
+ if ctx.isolation_lifecycle != "persistent":
979
+ return None
980
+ return worktree_summary.build_work_summary(
981
+ source_git_root=ctx.source_git_root,
982
+ execution_cwd=ctx.execution_cwd,
983
+ branch=ctx.branch,
984
+ creation_context=ctx.creation_context,
985
+ )
986
+
987
+
988
+ def _final_extra(ctx: RunContext, capture_exit_code: int) -> tuple[int, JsonObject]:
989
+ extra: JsonObject = {}
990
+ summary = _persistent_work_summary(ctx)
991
+ if summary is not None:
992
+ extra["workSummary"] = summary
993
+ if summary.get("noChanges") is True:
994
+ warnings = list(extra.get("warnings") or [])
995
+ _append_unique(
996
+ warnings,
997
+ "Work-mode run completed with no file changes or commits detected.",
998
+ )
999
+ extra["warnings"] = warnings
1000
+ commits_created = worktree_summary.commits_created_count(summary)
1001
+ if (
1002
+ summary is not None
1003
+ and commits_created is not None
1004
+ and commits_created > 0
1005
+ and not ctx.forbid_commit
1006
+ ):
1007
+ extra["warnings"] = [
1008
+ "Child command created commits; review the persistent worktree before integration."
1009
+ ]
1010
+ extra["nextActions"] = [
1011
+ f"delegate worktree show {ctx.alias}",
1012
+ f"git -C {shlex.quote(ctx.execution_cwd)} log --oneline --decorate --max-count=5 HEAD",
1013
+ ]
1014
+ extra["commitsCreatedByChild"] = True
1015
+ if ctx.forbid_commit:
1016
+ unverified = commits_created is None
1017
+ violated = commits_created is not None and commits_created > 0
1018
+ extra["commitPolicy"] = {
1019
+ "forbidCommit": True,
1020
+ "violated": violated,
1021
+ "verified": not unverified,
1022
+ "commitsCreatedCount": commits_created,
1023
+ }
1024
+ if unverified:
1025
+ extra["commitPolicyUnverified"] = True
1026
+ extra["childExitCode"] = capture_exit_code
1027
+ if capture_exit_code == 0:
1028
+ extra["error"] = "commit_policy_unverified"
1029
+ extra["message"] = (
1030
+ "Delegate could not verify --forbid-commit because final Git inspection failed."
1031
+ )
1032
+ extra["commitPolicyCausedFailure"] = True
1033
+ return 1, extra
1034
+ if violated:
1035
+ extra["commitPolicyViolated"] = True
1036
+ extra["childExitCode"] = capture_exit_code
1037
+ if capture_exit_code == 0:
1038
+ extra["error"] = "commit_policy_violated"
1039
+ extra["message"] = (
1040
+ "Child command created commits even though --forbid-commit was set."
1041
+ )
1042
+ extra["commitPolicyCausedFailure"] = True
1043
+ return 1, extra
1044
+ return capture_exit_code, extra
1045
+
1046
+
1047
+ @dataclass
1048
+ class ByteCounter:
1049
+ total: int = 0
1050
+
1051
+
1052
+ def _progress_interval_from_env(name: str, default: float) -> float:
1053
+ raw = os.environ.get(name)
1054
+ if raw is None:
1055
+ return default
1056
+ try:
1057
+ value = float(raw)
1058
+ except ValueError:
1059
+ return default
1060
+ if not math.isfinite(value) or value <= 0:
1061
+ return default
1062
+ return max(value, 0.01)
1063
+
1064
+
1065
+ def _progress_current_label(accumulator: harness_events.StreamAccumulator) -> str:
1066
+ current = (accumulator.current or "").strip()
1067
+ if not current:
1068
+ return "waiting for child output"
1069
+ return redaction.redact_progress_label(current)[:160]
1070
+
1071
+
1072
+ def _emit_progress_started(ctx: RunContext, stderr: TextIO) -> None:
1073
+ print(
1074
+ "delegate: run started "
1075
+ f"alias={ctx.alias} handle={ctx.alias} "
1076
+ f"snapshot={run_registry.snapshot_command(ctx.alias, cwd=ctx.source_cwd)!r}",
1077
+ file=stderr,
1078
+ flush=True,
1079
+ )
1080
+
1081
+
1082
+ def _emit_progress_heartbeat(
1083
+ ctx: RunContext,
1084
+ accumulator: harness_events.StreamAccumulator,
1085
+ *,
1086
+ started: float,
1087
+ stderr: TextIO,
1088
+ ) -> None:
1089
+ elapsed_ms = int((time.monotonic() - started) * MILLISECONDS_PER_SECOND)
1090
+ print(
1091
+ "delegate: still running "
1092
+ f"alias={ctx.alias} elapsed={format_duration(elapsed_ms)} "
1093
+ f"last_event={_progress_current_label(accumulator)!r}",
1094
+ file=stderr,
1095
+ flush=True,
1096
+ )
1097
+
1098
+
1099
+ def _prepare_tracked_run(
1100
+ argv: list[str],
1101
+ ctx: RunContext,
1102
+ *,
1103
+ manifest_argv: list[str] | None,
1104
+ ) -> TrackedRunFiles:
1105
+ run_path = run_registry.run_directory(ctx.registry_root, ctx.run_id)
1106
+ run_registry.ensure_private_dir(run_path)
1107
+ scratch_dir: Path | None = None
1108
+ if ctx.mode == "safe" or ctx.effective_isolation != "none":
1109
+ scratch_dir = run_path / "scratch"
1110
+ run_registry.ensure_private_dir(scratch_dir)
1111
+ write_manifest(run_path, build_manifest(ctx, manifest_argv or argv))
1112
+
1113
+ stdout_log = run_path / STDOUT_LOG
1114
+ stderr_log = run_path / STDERR_LOG
1115
+ run_registry.write_private_bytes(stdout_log, b"")
1116
+ run_registry.write_private_bytes(stderr_log, b"")
1117
+ return TrackedRunFiles(
1118
+ run_path=run_path,
1119
+ stdout_log=stdout_log,
1120
+ stderr_log=stderr_log,
1121
+ scratch_dir=scratch_dir,
1122
+ )
1123
+
1124
+
1125
+ def _env_overrides_with_scratch(
1126
+ env_overrides: dict[str, str] | None,
1127
+ scratch_dir: Path | None,
1128
+ ) -> dict[str, str] | None:
1129
+ if scratch_dir is None:
1130
+ return env_overrides
1131
+ return {
1132
+ **(env_overrides or {}),
1133
+ "TMPDIR": str(scratch_dir),
1134
+ "TMP": str(scratch_dir),
1135
+ "TEMP": str(scratch_dir),
1136
+ }
1137
+
1138
+
1139
+ def _codex_argv_with_scratch(argv: list[str], scratch_dir: Path | None) -> list[str]:
1140
+ if scratch_dir is None or "--sandbox" not in argv:
1141
+ return list(argv)
1142
+ sandbox_index = argv.index("--sandbox")
1143
+ if sandbox_index + 1 >= len(argv) or argv[sandbox_index + 1] != "read-only":
1144
+ return list(argv)
1145
+ updated = list(argv)
1146
+ insert_at = max(len(updated) - 1, 0)
1147
+ updated[insert_at:insert_at] = ["--add-dir", str(scratch_dir)]
1148
+ return updated
1149
+
1150
+
1151
+ def _launch_tracked_process(
1152
+ argv: list[str],
1153
+ cwd: str,
1154
+ *,
1155
+ stdin_text: str | None,
1156
+ env_overrides: dict[str, str] | None = None,
1157
+ scratch_dir: Path | None = None,
1158
+ ) -> subprocess.Popen[bytes]:
1159
+ env = profiles.child_environment(
1160
+ overrides=_env_overrides_with_scratch(env_overrides, scratch_dir)
1161
+ )
1162
+ return subprocess.Popen( # nosec B603 - Delegate intentionally launches validated harness argv with shell=False.
1163
+ argv,
1164
+ cwd=cwd,
1165
+ env=env,
1166
+ stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
1167
+ stdout=subprocess.PIPE,
1168
+ stderr=subprocess.PIPE,
1169
+ start_new_session=True,
1170
+ )
1171
+
1172
+
1173
+ def _runner_launch_error(argv: list[str], cwd: str, exc: OSError) -> RunnerLaunchError:
1174
+ binary = argv[0] if argv else "<empty argv>"
1175
+ return RunnerLaunchError(
1176
+ "child_launch_failed",
1177
+ f"Failed to launch child command {binary!r} in {cwd}: {exc}",
1178
+ )
1179
+
1180
+
1181
+ def _record_tracked_launch_failure(
1182
+ files: TrackedRunFiles,
1183
+ ctx: RunContext,
1184
+ error: RunnerLaunchError,
1185
+ ) -> None:
1186
+ # A launch failure never ran the child, so there is no result to classify.
1187
+ # resultQuality is set to None explicitly so build_state omits the key,
1188
+ # keeping state consistent with the snapshot below (which also omits it).
1189
+ extra: JsonObject = {
1190
+ "error": error.error,
1191
+ "message": error.message,
1192
+ "resultQuality": None,
1193
+ }
1194
+ write_state(files.run_path, build_state(ctx, status="failed", extra=extra))
1195
+ snapshot = build_snapshot(
1196
+ ctx, accumulator=harness_events.StreamAccumulator(harness=ctx.harness)
1197
+ )
1198
+ snapshot["ok"] = False
1199
+ snapshot["status"] = "failed"
1200
+ # The child never ran, so there is no result quality to report. Remove the
1201
+ # snapshot's default "ok" verdict so launch-failure state and snapshot agree.
1202
+ snapshot.pop("resultQuality", None)
1203
+ snapshot.update({"error": error.error, "message": error.message})
1204
+ write_snapshot(files.run_path, snapshot)
1205
+
1206
+
1207
+ def _capture_tracked_process(
1208
+ process: subprocess.Popen[bytes],
1209
+ files: TrackedRunFiles,
1210
+ ctx: RunContext,
1211
+ *,
1212
+ started: float,
1213
+ stdin_text: str | None,
1214
+ progress_stderr: TextIO | None = None,
1215
+ progress_initial_delay_sec: float = PROGRESS_INITIAL_DELAY_SEC,
1216
+ progress_interval_sec: float = PROGRESS_HEARTBEAT_INTERVAL_SEC,
1217
+ ) -> TrackedCaptureResult:
1218
+ accumulator = harness_events.StreamAccumulator(harness=ctx.harness)
1219
+ pgid = None
1220
+ with contextlib.suppress(OSError):
1221
+ pgid = os.getpgid(process.pid)
1222
+ persist_progress(files.run_path, ctx, accumulator, status="running", pid=process.pid)
1223
+
1224
+ line_buffer = ""
1225
+ stdout_bytes_counter = ByteCounter()
1226
+ stderr_bytes_counter = ByteCounter()
1227
+ lines_since_persist = 0
1228
+ last_persist_at = time.monotonic()
1229
+ progress_dirty = False
1230
+
1231
+ def maybe_persist_running() -> None:
1232
+ nonlocal lines_since_persist, last_persist_at, progress_dirty
1233
+ if not progress_dirty:
1234
+ return
1235
+ progress_dirty = False
1236
+ lines_since_persist = 0
1237
+ last_persist_at = time.monotonic()
1238
+ persist_progress(
1239
+ files.run_path,
1240
+ ctx,
1241
+ accumulator,
1242
+ status="running",
1243
+ pid=process.pid,
1244
+ stdout_bytes=stdout_bytes_counter.total,
1245
+ stderr_bytes=stderr_bytes_counter.total,
1246
+ )
1247
+
1248
+ if process.stdout is None or process.stderr is None:
1249
+ raise RunnerLaunchError(
1250
+ "missing_child_stream",
1251
+ "Child process did not expose stdout/stderr pipes for tracking.",
1252
+ )
1253
+ with open_events_log(files.run_path) as events_handle:
1254
+
1255
+ def handle_stdout_line(chunk_text: str) -> None:
1256
+ nonlocal line_buffer, lines_since_persist, last_persist_at, progress_dirty
1257
+ line_buffer += chunk_text
1258
+ while "\n" in line_buffer:
1259
+ line, line_buffer = line_buffer.split("\n", 1)
1260
+ accumulator.ingest_line(line)
1261
+ progress_dirty = True
1262
+ append_event(
1263
+ events_handle,
1264
+ {"kind": "stream.line", "stream": "stdout", "text": line[:500]},
1265
+ )
1266
+ lines_since_persist += 1
1267
+ elapsed = time.monotonic() - last_persist_at
1268
+ if (
1269
+ lines_since_persist >= PROGRESS_PERSIST_LINE_INTERVAL
1270
+ or elapsed >= PROGRESS_PERSIST_TIME_INTERVAL_SEC
1271
+ ):
1272
+ events_handle.flush()
1273
+ maybe_persist_running()
1274
+
1275
+ stdout_thread = threading.Thread(
1276
+ target=_drain_stream,
1277
+ args=(process.stdout, files.stdout_log, stdout_bytes_counter),
1278
+ kwargs={"on_line": handle_stdout_line},
1279
+ daemon=True,
1280
+ )
1281
+ stderr_thread = threading.Thread(
1282
+ target=_drain_stream,
1283
+ args=(process.stderr, files.stderr_log, stderr_bytes_counter),
1284
+ kwargs={"on_line": None},
1285
+ daemon=True,
1286
+ )
1287
+ stdout_thread.start()
1288
+ stderr_thread.start()
1289
+ stdin_thread: threading.Thread | None = None
1290
+ stdin_failures: list[str] = []
1291
+ if stdin_text is not None:
1292
+ stdin_thread = threading.Thread(
1293
+ target=_write_stdin,
1294
+ args=(process.stdin, stdin_text, stdin_failures),
1295
+ daemon=True,
1296
+ )
1297
+ stdin_thread.start()
1298
+ # Child agent runtimes are intentionally unbounded: callers cancel them
1299
+ # via the OS/run-management surface, while quick metadata probes
1300
+ # elsewhere use explicit timeouts.
1301
+ if progress_stderr is not None:
1302
+ emit_progress = True
1303
+ try:
1304
+ _emit_progress_started(ctx, progress_stderr)
1305
+ except (BrokenPipeError, OSError):
1306
+ emit_progress = False
1307
+ initial_delay = _progress_interval_from_env(
1308
+ PROGRESS_INITIAL_DELAY_ENV,
1309
+ progress_initial_delay_sec,
1310
+ )
1311
+ interval = _progress_interval_from_env(
1312
+ PROGRESS_INTERVAL_ENV,
1313
+ progress_interval_sec,
1314
+ )
1315
+ next_progress_at = time.monotonic() + initial_delay
1316
+ while True:
1317
+ if not emit_progress:
1318
+ exit_code = process.wait()
1319
+ break
1320
+ timeout = max(next_progress_at - time.monotonic(), 0.01)
1321
+ try:
1322
+ exit_code = process.wait(timeout=timeout)
1323
+ break
1324
+ except subprocess.TimeoutExpired:
1325
+ try:
1326
+ _emit_progress_heartbeat(
1327
+ ctx,
1328
+ accumulator,
1329
+ started=started,
1330
+ stderr=progress_stderr,
1331
+ )
1332
+ except (BrokenPipeError, OSError):
1333
+ emit_progress = False
1334
+ next_progress_at = time.monotonic() + interval
1335
+ else:
1336
+ exit_code = process.wait()
1337
+ _join_stdin_thread(stdin_thread, process.stdin)
1338
+ _join_drain_thread(stdout_thread, process.stdout)
1339
+ _join_drain_thread(stderr_thread, process.stderr)
1340
+ process.stdout.close()
1341
+ process.stderr.close()
1342
+ if line_buffer.strip():
1343
+ accumulator.ingest_line(line_buffer)
1344
+ return TrackedCaptureResult(
1345
+ accumulator=accumulator,
1346
+ exit_code=exit_code,
1347
+ duration_ms=int((time.monotonic() - started) * MILLISECONDS_PER_SECOND),
1348
+ stdout_bytes=stdout_bytes_counter.total,
1349
+ stderr_bytes=stderr_bytes_counter.total,
1350
+ stdin_failures=tuple(stdin_failures),
1351
+ pid=process.pid,
1352
+ pgid=pgid,
1353
+ )
1354
+
1355
+
1356
+ def _ctx_with_stdin_warnings(
1357
+ ctx: RunContext,
1358
+ stdin_failures: tuple[str, ...],
1359
+ stderr: TextIO,
1360
+ ) -> RunContext:
1361
+ if not stdin_failures:
1362
+ return ctx
1363
+ for failure in stdin_failures:
1364
+ print(f"warning: {failure}", file=stderr)
1365
+ return replace(ctx, warnings=(*ctx.warnings, *stdin_failures))
1366
+
1367
+
1368
+ def _completion_report_source(
1369
+ ctx: RunContext,
1370
+ accumulator: harness_events.StreamAccumulator,
1371
+ *,
1372
+ completion_report_mode: str,
1373
+ ) -> str:
1374
+ if accumulator.completion_text:
1375
+ return accumulator.completion_text
1376
+ if (
1377
+ completion_report_mode == delegate_config.COMPLETION_REPORT_MODE_MARKDOWN
1378
+ and ctx.harness != "codex"
1379
+ and ctx.engine != "codex"
1380
+ ):
1381
+ return accumulator.assistant_text
1382
+ return ""
1383
+
1384
+
1385
+ def _finalize_tracked_run(
1386
+ files: TrackedRunFiles,
1387
+ ctx: RunContext,
1388
+ capture: TrackedCaptureResult,
1389
+ *,
1390
+ completion_report_mode: str,
1391
+ extra: JsonObject | None = None,
1392
+ ) -> TrackedFinalization:
1393
+ exit_code, merged_extra = _final_extra(ctx, capture.exit_code)
1394
+ if extra:
1395
+ merged_extra = {**merged_extra, **extra}
1396
+ merged_extra = {**merged_extra, "pid": capture.pid}
1397
+ if capture.pgid is not None:
1398
+ merged_extra["pgid"] = capture.pgid
1399
+ terminal_extra = _terminal_override_extra(capture.accumulator)
1400
+ if terminal_extra:
1401
+ merged_extra = {**merged_extra, **terminal_extra}
1402
+ status = status_from_exit(exit_code)
1403
+ if capture.accumulator.terminal_status in {
1404
+ run_registry.STATUS_FAILED,
1405
+ run_registry.STATUS_CANCELLED,
1406
+ }:
1407
+ status = capture.accumulator.terminal_status
1408
+ if exit_code == 0:
1409
+ exit_code = 1
1410
+ # Marker protocol (finalize-first race): cancel stamps cancelRequested under
1411
+ # the registry lock BEFORE signaling. If the child exits 0 on SIGTERM and
1412
+ # the runner finalizes before cancel's post-grace terminal write, the
1413
+ # finalizer observes the marker here and treats the run as cancelled for
1414
+ # both report synthesis and the persisted finalization. This is a non-lock
1415
+ # read for the report/failure-reason decision; _persist_final_progress
1416
+ # re-reads under the lock and makes the authoritative persisted-status
1417
+ # decision, so a marker that disappears between reads cannot corrupt state.
1418
+ pre_state = run_registry.load_run_state_or_none(ctx.registry_root, ctx.run_id)
1419
+ cancel_requested = isinstance(pre_state, dict) and pre_state.get("cancelRequested") is True
1420
+ if cancel_requested and status != run_registry.STATUS_CANCELLED:
1421
+ status = run_registry.STATUS_CANCELLED
1422
+ if exit_code == 0:
1423
+ exit_code = 1
1424
+ merged_extra["failureReason"] = "cancelled_by_user"
1425
+ stderr_tail = profiles.read_bounded_stderr_tail(files.stderr_log)
1426
+ failure_reason = _failure_reason(
1427
+ status=status,
1428
+ stderr_tail=stderr_tail,
1429
+ accumulator=capture.accumulator,
1430
+ extra=merged_extra,
1431
+ )
1432
+ if failure_reason is not None:
1433
+ merged_extra["failureReason"] = failure_reason
1434
+ if failure_reason == "auth_failed":
1435
+ merged_extra["nextActions"] = _auth_remediation_actions(ctx)
1436
+ report_text, report_source = _completion_report_text_and_source(
1437
+ ctx,
1438
+ capture.accumulator,
1439
+ completion_report_mode=completion_report_mode,
1440
+ status=status,
1441
+ failure_reason=failure_reason,
1442
+ stderr_tail=stderr_tail,
1443
+ )
1444
+ report_written = write_completion_report(files.run_path, report_text)
1445
+ result_quality = _classify_result_quality(
1446
+ ctx=ctx,
1447
+ exit_code=exit_code,
1448
+ report_text=report_text,
1449
+ report_written=report_written,
1450
+ report_source=report_source,
1451
+ accumulator=capture.accumulator,
1452
+ )
1453
+ merged_extra["completionReportWritten"] = report_written
1454
+ merged_extra["completionReportSource"] = report_source if report_written else None
1455
+ merged_extra["resultQuality"] = result_quality
1456
+ if result_quality != RESULT_QUALITY_OK:
1457
+ warnings = list(merged_extra.get("warnings") or [])
1458
+ _append_unique(warnings, _quality_warning(result_quality, harness=ctx.harness))
1459
+ merged_extra["warnings"] = warnings
1460
+ persisted_status = _persist_final_progress(
1461
+ files.run_path,
1462
+ ctx,
1463
+ capture.accumulator,
1464
+ status=status,
1465
+ exit_code=exit_code,
1466
+ stdout_bytes=capture.stdout_bytes,
1467
+ stderr_bytes=capture.stderr_bytes,
1468
+ completion_report_written=report_written,
1469
+ extra=merged_extra,
1470
+ )
1471
+ # Cancel-precedence reconciliation: when the finalizer preserved a
1472
+ # concurrent cancel (persisted_status is cancelled but the runner's own
1473
+ # exit-code-derived status was not cancelled), the LIVE result returned to
1474
+ # the caller must agree with the persisted state. A cancelled run never
1475
+ # reports success, so normalize exit_code to 1, status to cancelled, and
1476
+ # the failure reason to cancelled_by_user. This keeps the CLI envelope
1477
+ # (ok/status/exitCode), the process exit code, and state.json in lockstep.
1478
+ if (
1479
+ persisted_status == run_registry.STATUS_CANCELLED
1480
+ and status != run_registry.STATUS_CANCELLED
1481
+ ):
1482
+ final_extra = dict(merged_extra)
1483
+ final_extra["failureReason"] = "cancelled_by_user"
1484
+ final_extra["exitCode"] = 1
1485
+ return TrackedFinalization(
1486
+ status=run_registry.STATUS_CANCELLED,
1487
+ exit_code=1,
1488
+ report_written=report_written,
1489
+ extra=final_extra,
1490
+ )
1491
+ return TrackedFinalization(
1492
+ status=persisted_status,
1493
+ exit_code=exit_code,
1494
+ report_written=report_written,
1495
+ extra=merged_extra,
1496
+ )
1497
+
1498
+
1499
+ def _tracked_result(
1500
+ ctx: RunContext,
1501
+ capture: TrackedCaptureResult,
1502
+ finalization: TrackedFinalization,
1503
+ *,
1504
+ json_mode: bool,
1505
+ stdout: TextIO,
1506
+ ) -> tuple[int, JsonObject | None]:
1507
+ ok = finalization.exit_code == 0
1508
+ if json_mode:
1509
+ _assistant_text, assistant_meta = capture.accumulator.bounded_assistant_text()
1510
+ payload = completion_json_payload(
1511
+ ctx,
1512
+ ok=ok,
1513
+ status=finalization.status,
1514
+ exit_code=finalization.exit_code,
1515
+ duration_ms=capture.duration_ms,
1516
+ stdout_bytes=capture.stdout_bytes,
1517
+ stderr_bytes=capture.stderr_bytes,
1518
+ completion_report_written=finalization.report_written,
1519
+ assistant_meta=assistant_meta,
1520
+ extra=finalization.extra,
1521
+ )
1522
+ return finalization.exit_code, payload
1523
+
1524
+ emit_bounded_text_summary(
1525
+ ctx,
1526
+ status=finalization.status,
1527
+ duration_ms=capture.duration_ms,
1528
+ stdout=stdout,
1529
+ extra=finalization.extra,
1530
+ )
1531
+ return finalization.exit_code, None
1532
+
1533
+
1534
+ def _append_attempt_delimiter(stderr_log: Path, *, label: str) -> None:
1535
+ marker = f"\n--- delegate codex auth attempt: {label} ---\n"
1536
+ with stderr_log.open("ab") as handle:
1537
+ handle.write(marker.encode("utf-8"))
1538
+
1539
+
1540
+ def _should_retry_profiles(
1541
+ ctx: RunContext,
1542
+ capture: TrackedCaptureResult,
1543
+ *,
1544
+ cwd: str,
1545
+ stderr_log: Path,
1546
+ workspace_baseline: profiles.WorkspaceBaseline | None,
1547
+ ) -> bool:
1548
+ if ctx.engine != "codex" or not ctx.fallback_env_overrides:
1549
+ return False
1550
+ if capture.exit_code == 0:
1551
+ return False
1552
+ stderr_tail = profiles.read_bounded_stderr_tail(stderr_log)
1553
+ if not profiles.classify_codex_usage_limit(stderr_tail):
1554
+ return False
1555
+ if profiles.accumulator_had_tool_events(capture.accumulator):
1556
+ return False
1557
+ return ctx.mode != "work" or profiles.work_mode_safe_for_codex_fallback(cwd, workspace_baseline)
1558
+
1559
+
1560
+ def _run_single_tracked_attempt(
1561
+ argv: list[str],
1562
+ cwd: str,
1563
+ files: TrackedRunFiles,
1564
+ ctx: RunContext,
1565
+ *,
1566
+ started: float,
1567
+ stdin_text: str | None,
1568
+ env_overrides: dict[str, str] | None,
1569
+ scratch_dir: Path | None,
1570
+ progress: bool,
1571
+ progress_stderr: TextIO | None,
1572
+ progress_initial_delay_sec: float,
1573
+ progress_interval_sec: float,
1574
+ attempt_label: str | None = None,
1575
+ ) -> TrackedCaptureResult:
1576
+ if attempt_label is not None:
1577
+ _append_attempt_delimiter(files.stderr_log, label=attempt_label)
1578
+ process = _launch_tracked_process(
1579
+ argv,
1580
+ cwd,
1581
+ stdin_text=stdin_text,
1582
+ env_overrides=env_overrides,
1583
+ scratch_dir=scratch_dir,
1584
+ )
1585
+ return _capture_tracked_process(
1586
+ process,
1587
+ files,
1588
+ ctx,
1589
+ started=started,
1590
+ stdin_text=stdin_text,
1591
+ progress_stderr=progress_stderr,
1592
+ progress_initial_delay_sec=progress_initial_delay_sec,
1593
+ progress_interval_sec=progress_interval_sec,
1594
+ )
1595
+
1596
+
1597
+ def execute_tracked(
1598
+ argv: list[str],
1599
+ cwd: str,
1600
+ ctx: RunContext,
1601
+ *,
1602
+ json_mode: bool,
1603
+ stdout: TextIO,
1604
+ stderr: TextIO,
1605
+ completion_report_mode: str = delegate_config.COMPLETION_REPORT_MODE_MARKDOWN,
1606
+ stdin_text: str | None = None,
1607
+ prompt_file_text: str | None = None,
1608
+ prompt_file_placeholder: str | None = None,
1609
+ manifest_argv: list[str] | None = None,
1610
+ progress: bool = False,
1611
+ progress_initial_delay_sec: float = PROGRESS_INITIAL_DELAY_SEC,
1612
+ progress_interval_sec: float = PROGRESS_HEARTBEAT_INTERVAL_SEC,
1613
+ ) -> tuple[int, JsonObject | None]:
1614
+ if stdin_text is not None and prompt_file_text is not None:
1615
+ raise ValueError("stdin_text and prompt_file_text are mutually exclusive")
1616
+ files = _prepare_tracked_run(argv, ctx, manifest_argv=manifest_argv)
1617
+ started = time.monotonic()
1618
+ run_argv = _codex_argv_with_scratch(argv, files.scratch_dir) if ctx.engine == "codex" else argv
1619
+ run_manifest_argv = (
1620
+ _codex_argv_with_scratch(manifest_argv, files.scratch_dir)
1621
+ if ctx.engine == "codex" and manifest_argv is not None
1622
+ else manifest_argv
1623
+ )
1624
+ if ctx.engine == "codex" and files.scratch_dir is not None:
1625
+ write_manifest(files.run_path, build_manifest(ctx, run_manifest_argv or run_argv))
1626
+ launch_argv, prompt_temp_dir = _materialize_prompt_file_argv(
1627
+ run_argv,
1628
+ prompt_file_text=prompt_file_text,
1629
+ prompt_file_placeholder=prompt_file_placeholder,
1630
+ )
1631
+ workspace_baseline = profiles.capture_workspace_baseline(cwd) if ctx.mode == "work" else None
1632
+ fallback_extra: JsonObject | None = None
1633
+ try:
1634
+ try:
1635
+ capture = _run_single_tracked_attempt(
1636
+ launch_argv,
1637
+ cwd,
1638
+ files,
1639
+ ctx,
1640
+ started=started,
1641
+ stdin_text=stdin_text,
1642
+ env_overrides=ctx.env_overrides or None,
1643
+ scratch_dir=files.scratch_dir,
1644
+ progress=progress,
1645
+ progress_stderr=stderr if progress else None,
1646
+ progress_initial_delay_sec=progress_initial_delay_sec,
1647
+ progress_interval_sec=progress_interval_sec,
1648
+ attempt_label="primary"
1649
+ if (ctx.engine == "codex" and ctx.fallback_env_overrides)
1650
+ else None,
1651
+ )
1652
+ except OSError as exc:
1653
+ error = _runner_launch_error(launch_argv, cwd, exc)
1654
+ _record_tracked_launch_failure(files, ctx, error)
1655
+ raise error from exc
1656
+
1657
+ if _should_retry_profiles(
1658
+ ctx,
1659
+ capture,
1660
+ cwd=cwd,
1661
+ stderr_log=files.stderr_log,
1662
+ workspace_baseline=workspace_baseline,
1663
+ ):
1664
+ primary_exit_code = capture.exit_code
1665
+ primary_stderr_tail = profiles.read_bounded_stderr_tail(files.stderr_log)
1666
+ fallback_capture = _run_single_tracked_attempt(
1667
+ launch_argv,
1668
+ cwd,
1669
+ files,
1670
+ ctx,
1671
+ started=started,
1672
+ stdin_text=stdin_text,
1673
+ env_overrides=ctx.fallback_env_overrides,
1674
+ scratch_dir=files.scratch_dir,
1675
+ progress=progress,
1676
+ progress_stderr=stderr if progress else None,
1677
+ progress_initial_delay_sec=progress_initial_delay_sec,
1678
+ progress_interval_sec=progress_interval_sec,
1679
+ attempt_label="fallback",
1680
+ )
1681
+ capture = fallback_capture
1682
+ fallback_extra = {
1683
+ "codexAuthFallback": profiles.codex_auth_fallback_metadata(
1684
+ reason="usage_limit",
1685
+ primary_auth_profile=ctx.auth_profile,
1686
+ fallback_auth_profile=ctx.fallback_auth_profile,
1687
+ primary_exit_code=primary_exit_code,
1688
+ fallback_exit_code=fallback_capture.exit_code,
1689
+ primary_stderr_tail=primary_stderr_tail,
1690
+ )
1691
+ }
1692
+ finally:
1693
+ _cleanup_prompt_file_dir(prompt_temp_dir)
1694
+
1695
+ ctx = _ctx_with_stdin_warnings(ctx, capture.stdin_failures, stderr)
1696
+ finalization = _finalize_tracked_run(
1697
+ files,
1698
+ ctx,
1699
+ capture,
1700
+ completion_report_mode=completion_report_mode,
1701
+ extra=fallback_extra,
1702
+ )
1703
+ return _tracked_result(ctx, capture, finalization, json_mode=json_mode, stdout=stdout)
1704
+
1705
+
1706
+ def execute_call(
1707
+ argv: list[str],
1708
+ cwd: str,
1709
+ *,
1710
+ harness: str,
1711
+ stdin_text: str | None = None,
1712
+ prompt_file_text: str | None = None,
1713
+ prompt_file_placeholder: str | None = None,
1714
+ env_overrides: dict[str, str] | None = None,
1715
+ ) -> CallResult:
1716
+ """Run a one-shot stateless model call and return parsed assistant text."""
1717
+ if stdin_text is not None and prompt_file_text is not None:
1718
+ raise ValueError("stdin_text and prompt_file_text are mutually exclusive")
1719
+ launch_argv, prompt_temp_dir = _materialize_prompt_file_argv(
1720
+ argv,
1721
+ prompt_file_text=prompt_file_text,
1722
+ prompt_file_placeholder=prompt_file_placeholder,
1723
+ )
1724
+ env = profiles.child_environment(overrides=env_overrides)
1725
+ started = time.monotonic()
1726
+ try:
1727
+ try:
1728
+ run_kwargs: dict[str, object] = {
1729
+ "cwd": cwd,
1730
+ "env": env,
1731
+ "stdout": subprocess.PIPE,
1732
+ "stderr": subprocess.PIPE,
1733
+ "check": False,
1734
+ }
1735
+ if stdin_text is None:
1736
+ run_kwargs["stdin"] = subprocess.DEVNULL
1737
+ else:
1738
+ run_kwargs["input"] = stdin_text.encode("utf-8")
1739
+ completed = subprocess.run( # nosec B603 - Delegate intentionally launches validated harness argv with shell=False.
1740
+ launch_argv,
1741
+ **run_kwargs,
1742
+ )
1743
+ except OSError as exc:
1744
+ raise _runner_launch_error(launch_argv, cwd, exc) from exc
1745
+ finally:
1746
+ _cleanup_prompt_file_dir(prompt_temp_dir)
1747
+
1748
+ stdout_bytes = len(completed.stdout or b"")
1749
+ stderr_bytes = len(completed.stderr or b"")
1750
+ stdout_text = (completed.stdout or b"").decode("utf-8", errors="replace")
1751
+ stderr_tail = _redacted_tail_from_bytes(completed.stderr or b"")
1752
+ accumulator = harness_events.StreamAccumulator(harness=harness)
1753
+ for line in stdout_text.splitlines():
1754
+ accumulator.ingest_line(line)
1755
+ text, meta = accumulator.bounded_assistant_text()
1756
+ warnings: tuple[str, ...] = ()
1757
+ if text:
1758
+ text_chars = int(meta.get("assistantTextChars", len(text)))
1759
+ text_truncated = bool(meta.get("assistantTextTruncated", False))
1760
+ elif accumulator.structured_events_seen > 0:
1761
+ warnings = (
1762
+ "Structured child stdout contained no assistant text; suppressed raw event output.",
1763
+ )
1764
+ text = ""
1765
+ text_chars = 0
1766
+ text_truncated = False
1767
+ else:
1768
+ # No structured assistant events parsed: fall back to raw stdout, bounded.
1769
+ raw = stdout_text.strip()
1770
+ text = _bounded_call_fallback_text(raw)
1771
+ text_chars = len(raw)
1772
+ text_truncated = len(raw) > harness_events.ASSISTANT_TEXT_LIMIT
1773
+ return CallResult(
1774
+ text=text,
1775
+ exit_code=completed.returncode,
1776
+ duration_ms=int((time.monotonic() - started) * MILLISECONDS_PER_SECOND),
1777
+ stdout_bytes=stdout_bytes,
1778
+ stderr_bytes=stderr_bytes,
1779
+ text_chars=text_chars,
1780
+ text_truncated=text_truncated,
1781
+ stderr_tail=stderr_tail,
1782
+ warnings=warnings,
1783
+ )
1784
+
1785
+
1786
+ def execute_passthrough(
1787
+ argv: list[str],
1788
+ cwd: str,
1789
+ *,
1790
+ stdin_text: str | None = None,
1791
+ prompt_file_text: str | None = None,
1792
+ prompt_file_placeholder: str | None = None,
1793
+ env_overrides: dict[str, str] | None = None,
1794
+ ) -> int:
1795
+ """Stream child stdout/stderr to the caller. JSON mode is not supported."""
1796
+ if stdin_text is not None and prompt_file_text is not None:
1797
+ raise ValueError("stdin_text and prompt_file_text are mutually exclusive")
1798
+ launch_argv, prompt_temp_dir = _materialize_prompt_file_argv(
1799
+ argv,
1800
+ prompt_file_text=prompt_file_text,
1801
+ prompt_file_placeholder=prompt_file_placeholder,
1802
+ )
1803
+ env = profiles.child_environment(overrides=env_overrides)
1804
+ try:
1805
+ # Passthrough mode mirrors the child runtime directly, so Delegate does
1806
+ # not impose a separate timeout here.
1807
+ try:
1808
+ if stdin_text is None:
1809
+ completed = subprocess.run( # nosec B603 - passthrough intentionally mirrors validated harness argv with shell=False.
1810
+ launch_argv,
1811
+ cwd=cwd,
1812
+ env=env,
1813
+ stdin=subprocess.DEVNULL,
1814
+ text=True,
1815
+ check=False,
1816
+ )
1817
+ else:
1818
+ completed = subprocess.run( # nosec B603 - passthrough intentionally mirrors validated harness argv with shell=False.
1819
+ launch_argv,
1820
+ cwd=cwd,
1821
+ env=env,
1822
+ input=stdin_text,
1823
+ text=True,
1824
+ check=False,
1825
+ )
1826
+ except OSError as exc:
1827
+ raise _runner_launch_error(launch_argv, cwd, exc) from exc
1828
+ return completed.returncode
1829
+ finally:
1830
+ _cleanup_prompt_file_dir(prompt_temp_dir)