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
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess # nosec B404 - Delegate inspects process identity with shell=False.
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from delegate_agent import command_errors, run_registry, snapshot_view
|
|
14
|
+
from delegate_agent import rendering as delegate_rendering
|
|
15
|
+
from delegate_agent.json_types import JsonObject
|
|
16
|
+
|
|
17
|
+
WAIT_SCHEMA = "delegate.wait.v1"
|
|
18
|
+
CANCEL_SCHEMA = "delegate.cancel.v1"
|
|
19
|
+
WAIT_DEFAULT_TIMEOUT_SECONDS = 3600
|
|
20
|
+
# A process may legitimately start up to this many seconds before the run's
|
|
21
|
+
# manifest startedAt is stamped (subprocess launch + manifest write latency).
|
|
22
|
+
# Used by the PID-reuse identity check as the allowed skew window.
|
|
23
|
+
PID_IDENTITY_SKEW_SECONDS = 60.0
|
|
24
|
+
WAIT_DEFAULT_INTERVAL_SECONDS = 3
|
|
25
|
+
WAIT_MIN_INTERVAL_SECONDS = 1
|
|
26
|
+
CANCEL_GRACE_SECONDS = 5.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class WaitCommand:
|
|
31
|
+
handles: tuple[str, ...]
|
|
32
|
+
latest_harness: str | None = None
|
|
33
|
+
group: str | None = None
|
|
34
|
+
timeout_seconds: int = WAIT_DEFAULT_TIMEOUT_SECONDS
|
|
35
|
+
interval_seconds: int = WAIT_DEFAULT_INTERVAL_SECONDS
|
|
36
|
+
completion_report: bool = False
|
|
37
|
+
json_mode: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class CancelCommand:
|
|
42
|
+
handles: tuple[str, ...]
|
|
43
|
+
json_mode: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WaitCancelError(command_errors.CommandError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _registry_for_workspace(workspace_path: str) -> Path:
|
|
51
|
+
workspace = Path(workspace_path)
|
|
52
|
+
return run_registry.registry_root_if_exists(workspace) or run_registry.registry_root(workspace)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _group_targets(registry_root: Path, group: str) -> list[run_registry.RunTarget]:
|
|
56
|
+
index = run_registry.load_index(registry_root)
|
|
57
|
+
runs = index.get("runs", {})
|
|
58
|
+
targets: list[run_registry.RunTarget] = []
|
|
59
|
+
for run_id, entry in runs.items():
|
|
60
|
+
if not isinstance(run_id, str) or not isinstance(entry, dict):
|
|
61
|
+
continue
|
|
62
|
+
if entry.get("group") != group:
|
|
63
|
+
continue
|
|
64
|
+
alias = entry.get("alias")
|
|
65
|
+
targets.append(run_registry.RunTarget(run_id, alias if isinstance(alias, str) else None))
|
|
66
|
+
|
|
67
|
+
def registration_ordinal(target: run_registry.RunTarget) -> int:
|
|
68
|
+
entry = runs.get(target.run_id)
|
|
69
|
+
if not isinstance(entry, dict):
|
|
70
|
+
return 0
|
|
71
|
+
ordinal = entry.get("registrationOrdinal", 0)
|
|
72
|
+
return ordinal if isinstance(ordinal, int) and not isinstance(ordinal, bool) else 0
|
|
73
|
+
|
|
74
|
+
targets.sort(key=registration_ordinal)
|
|
75
|
+
return targets
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_targets(
|
|
79
|
+
registry_root: Path,
|
|
80
|
+
handles: tuple[str, ...],
|
|
81
|
+
latest_harness: str | None,
|
|
82
|
+
group: str | None = None,
|
|
83
|
+
):
|
|
84
|
+
targets = []
|
|
85
|
+
seen: set[str] = set()
|
|
86
|
+
for handle in handles:
|
|
87
|
+
target = run_registry.resolve_run_target(
|
|
88
|
+
registry_root,
|
|
89
|
+
handle=handle,
|
|
90
|
+
latest_harness=None,
|
|
91
|
+
)
|
|
92
|
+
if isinstance(target, run_registry.RunTargetLookupError):
|
|
93
|
+
raise WaitCancelError(target.error, target.message)
|
|
94
|
+
if target.run_id not in seen:
|
|
95
|
+
targets.append(target)
|
|
96
|
+
seen.add(target.run_id)
|
|
97
|
+
if latest_harness is not None:
|
|
98
|
+
target = run_registry.resolve_run_target(
|
|
99
|
+
registry_root,
|
|
100
|
+
handle=None,
|
|
101
|
+
latest_harness=latest_harness,
|
|
102
|
+
)
|
|
103
|
+
if isinstance(target, run_registry.RunTargetLookupError):
|
|
104
|
+
raise WaitCancelError(target.error, target.message)
|
|
105
|
+
if target.run_id not in seen:
|
|
106
|
+
targets.append(target)
|
|
107
|
+
if group is not None:
|
|
108
|
+
for target in _group_targets(registry_root, group):
|
|
109
|
+
if target.run_id not in seen:
|
|
110
|
+
targets.append(target)
|
|
111
|
+
seen.add(target.run_id)
|
|
112
|
+
if not targets:
|
|
113
|
+
if group is not None:
|
|
114
|
+
raise WaitCancelError("no_matching_runs", f"No runs found for group: {group}")
|
|
115
|
+
raise WaitCancelError("missing_handle", "wait/cancel requires at least one run handle.")
|
|
116
|
+
return targets
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _merged_view(registry_root: Path, run_id: str, target: run_registry.RunTarget) -> JsonObject:
|
|
120
|
+
snapshot = run_registry.load_run_snapshot_or_none(registry_root, run_id)
|
|
121
|
+
view = snapshot_view.merge_snapshot_view(registry_root, run_id, snapshot, redact=True)
|
|
122
|
+
if target.resolution_kind != "literal":
|
|
123
|
+
view["requestedHandle"] = target.requested_handle
|
|
124
|
+
view["resolvedHandle"] = target.resolved_handle or target.alias or run_id
|
|
125
|
+
view["resolutionKind"] = target.resolution_kind
|
|
126
|
+
return dict(view)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _wait_state(registry_root: Path, run_id: str) -> JsonObject:
|
|
130
|
+
state = run_registry.load_run_state_or_none(registry_root, run_id)
|
|
131
|
+
fields = run_registry.status_fields(state)
|
|
132
|
+
status = fields.get("effectiveStatus")
|
|
133
|
+
result: JsonObject = {
|
|
134
|
+
"rawStatus": fields.get("rawStatus"),
|
|
135
|
+
"effectiveStatus": status,
|
|
136
|
+
"terminal": status in run_registry.TERMINAL_STATUSES,
|
|
137
|
+
}
|
|
138
|
+
if fields.get("staleReason"):
|
|
139
|
+
# A dead tracked child is terminal failure for wait, not an active stale state.
|
|
140
|
+
result["effectiveStatus"] = run_registry.STATUS_FAILED
|
|
141
|
+
result["terminal"] = True
|
|
142
|
+
result["staleReason"] = fields["staleReason"]
|
|
143
|
+
result["failureReason"] = fields["staleReason"]
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _terminal_payload(registry_root: Path, target: run_registry.RunTarget) -> JsonObject:
|
|
148
|
+
payload = _merged_view(registry_root, target.run_id, target)
|
|
149
|
+
wait_state = _wait_state(registry_root, target.run_id)
|
|
150
|
+
payload["rawStatus"] = wait_state.get("rawStatus")
|
|
151
|
+
payload["effectiveStatus"] = wait_state.get("effectiveStatus")
|
|
152
|
+
payload["status"] = wait_state.get("effectiveStatus")
|
|
153
|
+
if wait_state.get("staleReason"):
|
|
154
|
+
payload["staleReason"] = wait_state["staleReason"]
|
|
155
|
+
payload.setdefault("failureReason", wait_state.get("failureReason"))
|
|
156
|
+
return payload
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _status_label(payload: JsonObject) -> str:
|
|
160
|
+
return str(payload.get("status") or payload.get("effectiveStatus") or "unknown")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _print_wait_table(runs: list[JsonObject], stdout: TextIO) -> None:
|
|
164
|
+
print("alias status quality failure", file=stdout)
|
|
165
|
+
for run in runs:
|
|
166
|
+
alias = str(run.get("alias") or run.get("runId") or "?")[:12]
|
|
167
|
+
status = _status_label(run)[:10]
|
|
168
|
+
quality = str(run.get("resultQuality") or "")[:16]
|
|
169
|
+
failure = str(run.get("failureReason") or run.get("staleReason") or "")[:40]
|
|
170
|
+
print(f"{alias:<12} {status:<10} {quality:<16} {failure}", file=stdout)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _append_reports(
|
|
174
|
+
runs: list[JsonObject],
|
|
175
|
+
*,
|
|
176
|
+
registry_root: Path,
|
|
177
|
+
targets: list[run_registry.RunTarget],
|
|
178
|
+
json_mode: bool,
|
|
179
|
+
stdout: TextIO,
|
|
180
|
+
) -> None:
|
|
181
|
+
for run, target in zip(runs, targets, strict=True):
|
|
182
|
+
# Keep JSON/text behavior simple and local: read the report file the same
|
|
183
|
+
# run-output command would prefer after Wave 2 synthesized failure reports.
|
|
184
|
+
path = (
|
|
185
|
+
run_registry.run_directory(registry_root, target.run_id)
|
|
186
|
+
/ run_registry.COMPLETION_REPORT_FILE
|
|
187
|
+
)
|
|
188
|
+
report = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
|
189
|
+
if json_mode:
|
|
190
|
+
run["completionReportContent"] = report
|
|
191
|
+
else:
|
|
192
|
+
print(f"\n=== {run.get('alias') or target.run_id} completionReport ===", file=stdout)
|
|
193
|
+
print(report, end="" if report.endswith("\n") else "\n", file=stdout)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def emit_wait(command: WaitCommand, *, workspace_path: str, stdout: TextIO) -> int:
|
|
197
|
+
registry_root = _registry_for_workspace(workspace_path)
|
|
198
|
+
targets = _resolve_targets(
|
|
199
|
+
registry_root,
|
|
200
|
+
command.handles,
|
|
201
|
+
command.latest_harness,
|
|
202
|
+
command.group,
|
|
203
|
+
)
|
|
204
|
+
deadline = time.monotonic() + command.timeout_seconds
|
|
205
|
+
last_statuses: dict[str, str] = {}
|
|
206
|
+
timed_out = False
|
|
207
|
+
|
|
208
|
+
while True:
|
|
209
|
+
states = {target.run_id: _wait_state(registry_root, target.run_id) for target in targets}
|
|
210
|
+
if not command.json_mode:
|
|
211
|
+
for target in targets:
|
|
212
|
+
status = str(states[target.run_id].get("effectiveStatus") or "unknown")
|
|
213
|
+
if last_statuses.get(target.run_id) != status:
|
|
214
|
+
print(f"{target.alias or target.run_id}: {status}", file=stdout)
|
|
215
|
+
last_statuses[target.run_id] = status
|
|
216
|
+
if all(state.get("terminal") for state in states.values()):
|
|
217
|
+
break
|
|
218
|
+
if time.monotonic() >= deadline:
|
|
219
|
+
timed_out = True
|
|
220
|
+
break
|
|
221
|
+
time.sleep(command.interval_seconds)
|
|
222
|
+
|
|
223
|
+
runs = [_terminal_payload(registry_root, target) for target in targets]
|
|
224
|
+
if command.completion_report and command.json_mode:
|
|
225
|
+
_append_reports(
|
|
226
|
+
runs,
|
|
227
|
+
registry_root=registry_root,
|
|
228
|
+
targets=targets,
|
|
229
|
+
json_mode=command.json_mode,
|
|
230
|
+
stdout=stdout,
|
|
231
|
+
)
|
|
232
|
+
if command.json_mode:
|
|
233
|
+
delegate_rendering.print_json(
|
|
234
|
+
{
|
|
235
|
+
"ok": not timed_out
|
|
236
|
+
and all(_status_label(run) == run_registry.STATUS_SUCCEEDED for run in runs),
|
|
237
|
+
"schema": WAIT_SCHEMA,
|
|
238
|
+
"timedOut": timed_out,
|
|
239
|
+
"runs": runs,
|
|
240
|
+
},
|
|
241
|
+
stdout,
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
_print_wait_table(runs, stdout)
|
|
245
|
+
if command.completion_report:
|
|
246
|
+
_append_reports(
|
|
247
|
+
runs,
|
|
248
|
+
registry_root=registry_root,
|
|
249
|
+
targets=targets,
|
|
250
|
+
json_mode=command.json_mode,
|
|
251
|
+
stdout=stdout,
|
|
252
|
+
)
|
|
253
|
+
# Exit-code precedence: any failed/cancelled run -> 1 (even if others timed
|
|
254
|
+
# out); only timeouts (no terminal failure, but deadline hit) -> 124; all
|
|
255
|
+
# succeeded -> 0. A non-terminal run that did not fail counts as a timeout
|
|
256
|
+
# when the deadline was hit.
|
|
257
|
+
failure_statuses = {run_registry.STATUS_FAILED, run_registry.STATUS_CANCELLED}
|
|
258
|
+
any_failure = any(_status_label(run) in failure_statuses for run in runs)
|
|
259
|
+
if any_failure:
|
|
260
|
+
return 1
|
|
261
|
+
if timed_out:
|
|
262
|
+
return 124
|
|
263
|
+
return 0 if all(_status_label(run) == run_registry.STATUS_SUCCEEDED for run in runs) else 1
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _signal_target_alive(value: int, *, process_group: bool) -> bool:
|
|
267
|
+
try:
|
|
268
|
+
if process_group:
|
|
269
|
+
os.killpg(value, 0)
|
|
270
|
+
else:
|
|
271
|
+
os.kill(value, 0)
|
|
272
|
+
except ProcessLookupError:
|
|
273
|
+
return False
|
|
274
|
+
except PermissionError:
|
|
275
|
+
return True
|
|
276
|
+
except OSError:
|
|
277
|
+
return False
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _send_signal(value: int, sig: signal.Signals, *, process_group: bool) -> None:
|
|
282
|
+
if value <= 1:
|
|
283
|
+
raise WaitCancelError("unsafe_signal_target", f"Refusing to signal pid/pgid <= 1: {value}")
|
|
284
|
+
if process_group:
|
|
285
|
+
os.killpg(value, sig)
|
|
286
|
+
else:
|
|
287
|
+
os.kill(value, sig)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _process_start_datetime(pid: int) -> datetime | None:
|
|
291
|
+
"""Return the process start time for ``pid`` via ``ps -o lstart=``, or None
|
|
292
|
+
if ps is unavailable or the output is unparseable (soft-degrade).
|
|
293
|
+
|
|
294
|
+
Uses ``LC_ALL=C`` so the asctime format is locale-stable on macOS and Linux.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
completed = subprocess.run( # nosec B603 - fixed ps argv, shell=False.
|
|
298
|
+
["ps", "-o", "lstart=", "-p", str(pid)],
|
|
299
|
+
capture_output=True,
|
|
300
|
+
text=True,
|
|
301
|
+
check=False,
|
|
302
|
+
env={**os.environ, "LC_ALL": "C"},
|
|
303
|
+
timeout=5.0,
|
|
304
|
+
)
|
|
305
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
306
|
+
return None
|
|
307
|
+
raw = completed.stdout.strip()
|
|
308
|
+
if not raw or completed.returncode != 0:
|
|
309
|
+
return None
|
|
310
|
+
# ps lstart prints an asctime-style string, e.g. "Thu Jul 4 12:00:00 2026".
|
|
311
|
+
# email.utils.parsedate_to_datetime parses RFC-2822 dates but also handles
|
|
312
|
+
# the asctime format (day-of-week abbreviated month day time year) in a
|
|
313
|
+
# locale-stable way under LC_ALL=C. The output is in the system's local
|
|
314
|
+
# timezone (ps has no timezone flag), so we interpret the naive result as
|
|
315
|
+
# local time and convert to UTC for comparison against the run's manifest
|
|
316
|
+
# startedAt (which is UTC).
|
|
317
|
+
try:
|
|
318
|
+
parsed = parsedate_to_datetime(raw)
|
|
319
|
+
except (TypeError, ValueError):
|
|
320
|
+
return None
|
|
321
|
+
if parsed is None:
|
|
322
|
+
return None
|
|
323
|
+
if parsed.tzinfo is None:
|
|
324
|
+
# ps lstart is local time; convert to UTC.
|
|
325
|
+
parsed = parsed.astimezone(UTC)
|
|
326
|
+
return parsed
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _check_pid_identity(
|
|
330
|
+
registry_root: Path,
|
|
331
|
+
target: run_registry.RunTarget,
|
|
332
|
+
pid: int,
|
|
333
|
+
) -> list[str]:
|
|
334
|
+
"""Verify the tracked pid is not older than the run (PID-reuse guard).
|
|
335
|
+
|
|
336
|
+
Returns a list of soft-degrade warnings (e.g. when ps is unavailable).
|
|
337
|
+
Raises WaitCancelError with ``pid_identity_mismatch`` if the process
|
|
338
|
+
predates the run's manifest startedAt beyond the allowed skew window,
|
|
339
|
+
indicating the original child is gone and the pid was reused.
|
|
340
|
+
"""
|
|
341
|
+
manifest = run_registry.load_run_manifest_or_none(registry_root, target.run_id)
|
|
342
|
+
started_at_str = manifest.get("startedAt") if isinstance(manifest, dict) else None
|
|
343
|
+
if not isinstance(started_at_str, str) or not started_at_str:
|
|
344
|
+
# No manifest startedAt to compare against; soft-degrade.
|
|
345
|
+
return ["pid identity check skipped: run manifest has no startedAt"]
|
|
346
|
+
started_at = run_registry.parse_utc_timestamp(started_at_str)
|
|
347
|
+
if started_at is None:
|
|
348
|
+
return ["pid identity check skipped: run manifest startedAt unparseable"]
|
|
349
|
+
proc_start = _process_start_datetime(pid)
|
|
350
|
+
if proc_start is None:
|
|
351
|
+
# ps failed or output unparseable: never hard-block cancel on ps quirks.
|
|
352
|
+
return [
|
|
353
|
+
"pid identity check skipped: ps lstart unavailable or unparseable; "
|
|
354
|
+
"proceeding without start-identity verification"
|
|
355
|
+
]
|
|
356
|
+
# The process may start up to PID_IDENTITY_SKEW_SECONDS before startedAt is
|
|
357
|
+
# stamped (launch + manifest write latency). If it predates the run beyond
|
|
358
|
+
# that skew, the original child is gone and the pid was reused.
|
|
359
|
+
skew = timedelta(seconds=PID_IDENTITY_SKEW_SECONDS)
|
|
360
|
+
if proc_start + skew < started_at:
|
|
361
|
+
raise WaitCancelError(
|
|
362
|
+
"pid_identity_mismatch",
|
|
363
|
+
f"Run {target.alias or target.run_id}: the tracked pid {pid} started at "
|
|
364
|
+
f"{proc_start.isoformat()}, which predates the run's startedAt "
|
|
365
|
+
f"{started_at.isoformat()} beyond the {PID_IDENTITY_SKEW_SECONDS:.0f}s skew "
|
|
366
|
+
"window. The original child process is gone and the pid was likely reused. "
|
|
367
|
+
"Refusing to signal a process that is not the run's child.",
|
|
368
|
+
)
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _state_int(state: JsonObject | None, key: str) -> int | None:
|
|
373
|
+
value = state.get(key) if isinstance(state, dict) else None
|
|
374
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
375
|
+
return value
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _cancel_target(registry_root: Path, target: run_registry.RunTarget) -> JsonObject:
|
|
380
|
+
state = run_registry.load_run_state_or_none(registry_root, target.run_id)
|
|
381
|
+
fields = run_registry.status_fields(state)
|
|
382
|
+
effective = fields.get("effectiveStatus")
|
|
383
|
+
if effective in run_registry.TERMINAL_STATUSES or effective == run_registry.STATUS_STALE:
|
|
384
|
+
raise WaitCancelError(
|
|
385
|
+
"run_already_terminal",
|
|
386
|
+
f"Run {target.alias or target.run_id} is already terminal ({effective}).",
|
|
387
|
+
)
|
|
388
|
+
pgid = _state_int(state, "pgid")
|
|
389
|
+
pid = _state_int(state, "pid")
|
|
390
|
+
warnings: list[str] = []
|
|
391
|
+
process_group = True
|
|
392
|
+
signal_value = pgid
|
|
393
|
+
if signal_value is None:
|
|
394
|
+
signal_value = pid
|
|
395
|
+
process_group = False
|
|
396
|
+
warnings.append("pgid missing; fell back to pid signal for legacy run")
|
|
397
|
+
if signal_value is None:
|
|
398
|
+
raise WaitCancelError(
|
|
399
|
+
"missing_pid", f"Run {target.alias or target.run_id} has no pid/pgid."
|
|
400
|
+
)
|
|
401
|
+
if signal_value <= 1:
|
|
402
|
+
raise WaitCancelError(
|
|
403
|
+
"unsafe_signal_target", f"Refusing to signal pid/pgid <= 1: {signal_value}"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# PID-reuse start-identity guard: verify the tracked leader pid is not older
|
|
407
|
+
# than the run. If the original child is gone and the pid was reused, refuse
|
|
408
|
+
# rather than signal an unrelated process. Soft-degrades (warning) when ps
|
|
409
|
+
# is unavailable or the manifest lacks a parseable startedAt.
|
|
410
|
+
identity_pid = pid if pid is not None else signal_value
|
|
411
|
+
warnings.extend(_check_pid_identity(registry_root, target, identity_pid))
|
|
412
|
+
|
|
413
|
+
# Marker protocol: stamp cancelRequested BEFORE sending any signal, under the
|
|
414
|
+
# registry lock. This lets the runner finalizer (which acquires the same
|
|
415
|
+
# lock) observe the marker even if the child exits 0 on SIGTERM and the
|
|
416
|
+
# finalizer runs before cancel's post-grace terminal write. Only stamp when
|
|
417
|
+
# the run is not already terminal: the top-of-function refusal already
|
|
418
|
+
# rejected terminal runs, but the runner may have finalized during the
|
|
419
|
+
# identity-check window, so re-check under the lock. A terminal run keeps
|
|
420
|
+
# its existing status (cancel's final locked write reconciles to cancelled).
|
|
421
|
+
cancel_requested_at = run_registry.utc_now_iso()
|
|
422
|
+
with run_registry.registry_lock(registry_root):
|
|
423
|
+
pre_signal = run_registry.load_run_state_or_none(registry_root, target.run_id)
|
|
424
|
+
pre_fields = run_registry.status_fields(pre_signal)
|
|
425
|
+
pre_effective = pre_fields.get("effectiveStatus")
|
|
426
|
+
if (
|
|
427
|
+
pre_effective not in run_registry.TERMINAL_STATUSES
|
|
428
|
+
and pre_effective != run_registry.STATUS_STALE
|
|
429
|
+
):
|
|
430
|
+
stamped = dict(pre_signal or state or {})
|
|
431
|
+
stamped["cancelRequested"] = True
|
|
432
|
+
stamped["cancelRequestedAt"] = cancel_requested_at
|
|
433
|
+
run_registry.write_json_atomic(
|
|
434
|
+
run_registry.run_directory(registry_root, target.run_id) / run_registry.STATE_FILE,
|
|
435
|
+
stamped,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
_send_signal(signal_value, signal.SIGTERM, process_group=process_group)
|
|
439
|
+
deadline = time.monotonic() + CANCEL_GRACE_SECONDS
|
|
440
|
+
while time.monotonic() < deadline:
|
|
441
|
+
alive = _signal_target_alive(signal_value, process_group=process_group)
|
|
442
|
+
if alive is False:
|
|
443
|
+
break
|
|
444
|
+
time.sleep(0.05)
|
|
445
|
+
alive = _signal_target_alive(signal_value, process_group=process_group)
|
|
446
|
+
if alive is not False:
|
|
447
|
+
try:
|
|
448
|
+
_send_signal(signal_value, signal.SIGKILL, process_group=process_group)
|
|
449
|
+
except ProcessLookupError:
|
|
450
|
+
pass
|
|
451
|
+
except PermissionError:
|
|
452
|
+
warnings.append("SIGKILL was not permitted after SIGTERM; run state marked cancelled")
|
|
453
|
+
|
|
454
|
+
run_path = run_registry.run_directory(registry_root, target.run_id)
|
|
455
|
+
stdout_bytes, stderr_bytes = run_registry.effective_log_byte_sizes(
|
|
456
|
+
registry_root, target.run_id, state
|
|
457
|
+
)
|
|
458
|
+
now = run_registry.utc_now_iso()
|
|
459
|
+
# Perform the terminal state write under the registry lock, re-reading
|
|
460
|
+
# state first. If the runner finalizer already wrote a terminal status
|
|
461
|
+
# (succeeded/failed) during the signal/grace window, cancel wins: reconcile
|
|
462
|
+
# to cancelled rather than blind-overwriting. This pairs with the runner
|
|
463
|
+
# finalizer's own cancel-precedence check so both race orders converge on
|
|
464
|
+
# cancelled.
|
|
465
|
+
with run_registry.registry_lock(registry_root):
|
|
466
|
+
latest = run_registry.load_run_state_or_none(registry_root, target.run_id)
|
|
467
|
+
updated: JsonObject = dict(latest or state or {})
|
|
468
|
+
runner_terminal = (
|
|
469
|
+
isinstance(latest, dict)
|
|
470
|
+
and latest.get("status") in run_registry.TERMINAL_STATUSES
|
|
471
|
+
and latest.get("status") != run_registry.STATUS_CANCELLED
|
|
472
|
+
)
|
|
473
|
+
if runner_terminal:
|
|
474
|
+
# The runner wrote a terminal status after our initial liveness
|
|
475
|
+
# check. Preserve its work summary/output metadata (exitCode, byte
|
|
476
|
+
# counts, completion report, result quality) but override status to
|
|
477
|
+
# cancelled per the cancel-wins precedence rule.
|
|
478
|
+
updated["status"] = run_registry.STATUS_CANCELLED
|
|
479
|
+
updated["failureReason"] = "cancelled_by_user"
|
|
480
|
+
updated["finishedAt"] = now
|
|
481
|
+
updated["lastActivityAt"] = now
|
|
482
|
+
updated["stdoutBytes"] = stdout_bytes
|
|
483
|
+
updated["stderrBytes"] = stderr_bytes
|
|
484
|
+
else:
|
|
485
|
+
updated.update(
|
|
486
|
+
{
|
|
487
|
+
"schema": run_registry.STATE_SCHEMA,
|
|
488
|
+
"runId": target.run_id,
|
|
489
|
+
"alias": target.alias,
|
|
490
|
+
"status": run_registry.STATUS_CANCELLED,
|
|
491
|
+
"failureReason": "cancelled_by_user",
|
|
492
|
+
"exitCode": 1,
|
|
493
|
+
"finishedAt": now,
|
|
494
|
+
"lastActivityAt": now,
|
|
495
|
+
"stdoutBytes": stdout_bytes,
|
|
496
|
+
"stderrBytes": stderr_bytes,
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
if warnings:
|
|
500
|
+
existing = updated.get("warnings") if isinstance(updated.get("warnings"), list) else []
|
|
501
|
+
updated["warnings"] = [
|
|
502
|
+
*existing,
|
|
503
|
+
*(warning for warning in warnings if warning not in existing),
|
|
504
|
+
]
|
|
505
|
+
run_registry.write_json_atomic(run_path / run_registry.STATE_FILE, updated)
|
|
506
|
+
|
|
507
|
+
snapshot = dict(run_registry.load_run_snapshot_or_none(registry_root, target.run_id) or {})
|
|
508
|
+
snapshot.update(
|
|
509
|
+
{
|
|
510
|
+
"schema": run_registry.SNAPSHOT_SCHEMA,
|
|
511
|
+
"ok": False,
|
|
512
|
+
"runId": target.run_id,
|
|
513
|
+
"alias": target.alias,
|
|
514
|
+
"status": run_registry.STATUS_CANCELLED,
|
|
515
|
+
"failureReason": "cancelled_by_user",
|
|
516
|
+
"finishedAt": now,
|
|
517
|
+
"stdoutBytes": stdout_bytes,
|
|
518
|
+
"stderrBytes": stderr_bytes,
|
|
519
|
+
}
|
|
520
|
+
)
|
|
521
|
+
if runner_terminal and isinstance(latest, dict):
|
|
522
|
+
# Preserve the runner's exit code in the snapshot when reconciling.
|
|
523
|
+
runner_exit = latest.get("exitCode")
|
|
524
|
+
if isinstance(runner_exit, int):
|
|
525
|
+
snapshot["exitCode"] = runner_exit
|
|
526
|
+
else:
|
|
527
|
+
snapshot["exitCode"] = 1
|
|
528
|
+
else:
|
|
529
|
+
snapshot["exitCode"] = 1
|
|
530
|
+
if warnings:
|
|
531
|
+
existing = (
|
|
532
|
+
snapshot.get("warnings") if isinstance(snapshot.get("warnings"), list) else []
|
|
533
|
+
)
|
|
534
|
+
snapshot["warnings"] = [
|
|
535
|
+
*existing,
|
|
536
|
+
*(warning for warning in warnings if warning not in existing),
|
|
537
|
+
]
|
|
538
|
+
run_registry.write_json_atomic(run_path / run_registry.SNAPSHOT_FILE, snapshot)
|
|
539
|
+
payload = _terminal_payload(registry_root, target)
|
|
540
|
+
if warnings:
|
|
541
|
+
payload["warnings"] = warnings
|
|
542
|
+
return payload
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def emit_cancel(command: CancelCommand, *, workspace_path: str, stdout: TextIO) -> int:
|
|
546
|
+
registry_root = _registry_for_workspace(workspace_path)
|
|
547
|
+
targets = _resolve_targets(registry_root, command.handles, None)
|
|
548
|
+
runs = [_cancel_target(registry_root, target) for target in targets]
|
|
549
|
+
if command.json_mode:
|
|
550
|
+
delegate_rendering.print_json(
|
|
551
|
+
{"ok": True, "schema": CANCEL_SCHEMA, "runs": runs},
|
|
552
|
+
stdout,
|
|
553
|
+
)
|
|
554
|
+
return 0
|
|
555
|
+
for run in runs:
|
|
556
|
+
print(f"cancelled: {run.get('alias') or run.get('runId')}", file=stdout)
|
|
557
|
+
for warning in run.get("warnings") or []:
|
|
558
|
+
print(f"warning: {warning}", file=stdout)
|
|
559
|
+
return 0
|