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.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
delegate_agent/runner.py
ADDED
|
@@ -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)
|