delegate-agent-cli 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. delegate_agent/__init__.py +5 -0
  2. delegate_agent/archived_logs.py +44 -0
  3. delegate_agent/argv_builders.py +423 -0
  4. delegate_agent/argv_utils.py +36 -0
  5. delegate_agent/capability_commands.py +99 -0
  6. delegate_agent/cli.py +987 -0
  7. delegate_agent/cli_parser.py +1627 -0
  8. delegate_agent/command_errors.py +29 -0
  9. delegate_agent/command_help.py +1264 -0
  10. delegate_agent/config.py +1117 -0
  11. delegate_agent/config_commands.py +143 -0
  12. delegate_agent/constants.py +50 -0
  13. delegate_agent/describe_payload.py +1018 -0
  14. delegate_agent/errors.py +32 -0
  15. delegate_agent/git_utils.py +180 -0
  16. delegate_agent/harness_events.py +719 -0
  17. delegate_agent/inspection_commands.py +104 -0
  18. delegate_agent/isolation.py +316 -0
  19. delegate_agent/json_types.py +18 -0
  20. delegate_agent/log_output.py +78 -0
  21. delegate_agent/private_io.py +109 -0
  22. delegate_agent/profile_commands.py +42 -0
  23. delegate_agent/profile_guard.py +116 -0
  24. delegate_agent/profiles.py +389 -0
  25. delegate_agent/prompt_instructions.py +39 -0
  26. delegate_agent/prompt_transport.py +12 -0
  27. delegate_agent/reasoning.py +916 -0
  28. delegate_agent/redaction.py +193 -0
  29. delegate_agent/rendering.py +573 -0
  30. delegate_agent/request_build.py +1681 -0
  31. delegate_agent/request_models.py +191 -0
  32. delegate_agent/retention.py +321 -0
  33. delegate_agent/run_metadata.py +78 -0
  34. delegate_agent/run_output_commands.py +645 -0
  35. delegate_agent/run_registry.py +747 -0
  36. delegate_agent/run_status.py +300 -0
  37. delegate_agent/runner.py +1830 -0
  38. delegate_agent/safe_workspace.py +821 -0
  39. delegate_agent/snapshot_view.py +229 -0
  40. delegate_agent/wait_cancel_commands.py +559 -0
  41. delegate_agent/worktree_commands.py +218 -0
  42. delegate_agent/worktree_execution.py +654 -0
  43. delegate_agent/worktree_gc.py +529 -0
  44. delegate_agent/worktree_mgmt.py +782 -0
  45. delegate_agent/worktree_records.py +232 -0
  46. delegate_agent/worktree_remove.py +547 -0
  47. delegate_agent/worktree_summary.py +242 -0
  48. delegate_agent/wsl.py +65 -0
  49. delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
  50. delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
  51. delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
  52. delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
  53. delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
  54. delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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