duet-cli 0.2.10__tar.gz → 0.2.11__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.10
3
+ Version: 0.2.11
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -235,9 +235,19 @@ stop, duet opens a `force>` prompt so you can push another round.
235
235
  Every run writes a directory with `transcript.md`, `state.json`, per-turn
236
236
  stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
237
237
  `duet --status <run-id> --json` (or omit `--json` for the human view), list runs
238
- with `duet --list`, and start a fresh run
238
+ with `duet --list`, stop one exact live run with `duet --stop <run-id>`, and
239
+ start a fresh run
239
240
  from saved state with `duet --continue <run> --task "next thing"`.
240
241
 
242
+ `--stop` is graceful. It lets the current agent turn finish before duet records
243
+ `force_stop`. Use `--stop <run-id> --immediate` to terminate that run's active
244
+ child and record `force_stop` now. Both forms validate the saved supervisor PID
245
+ and process-start identity. They never use a process-name match or signal a
246
+ different duet run.
247
+
248
+ If status remains `awaiting_force`, press Enter in the original terminal.
249
+ Some platforms do not wake the blocking `force>` input after an external signal.
250
+
241
251
  - **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
242
252
  - **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
243
253
  - **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
@@ -254,7 +264,7 @@ a deep-reasoning turn is stuck.
254
264
 
255
265
  [docs/USAGE.md](https://github.com/volkan/duet/blob/main/docs/USAGE.md) is the
256
266
  full reference: every flag, reasoning levels, session memory, output layout,
257
- `--status` / `--continue`, the force prompt, Codex sandbox and network rules,
267
+ `--status` / `--stop` / `--continue`, the force prompt, Codex sandbox and network rules,
258
268
  and worktree mode.
259
269
 
260
270
  ## Contributing
@@ -214,9 +214,19 @@ stop, duet opens a `force>` prompt so you can push another round.
214
214
  Every run writes a directory with `transcript.md`, `state.json`, per-turn
215
215
  stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
216
216
  `duet --status <run-id> --json` (or omit `--json` for the human view), list runs
217
- with `duet --list`, and start a fresh run
217
+ with `duet --list`, stop one exact live run with `duet --stop <run-id>`, and
218
+ start a fresh run
218
219
  from saved state with `duet --continue <run> --task "next thing"`.
219
220
 
221
+ `--stop` is graceful. It lets the current agent turn finish before duet records
222
+ `force_stop`. Use `--stop <run-id> --immediate` to terminate that run's active
223
+ child and record `force_stop` now. Both forms validate the saved supervisor PID
224
+ and process-start identity. They never use a process-name match or signal a
225
+ different duet run.
226
+
227
+ If status remains `awaiting_force`, press Enter in the original terminal.
228
+ Some platforms do not wake the blocking `force>` input after an external signal.
229
+
220
230
  - **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
221
231
  - **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
222
232
  - **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
@@ -233,7 +243,7 @@ a deep-reasoning turn is stuck.
233
243
 
234
244
  [docs/USAGE.md](https://github.com/volkan/duet/blob/main/docs/USAGE.md) is the
235
245
  full reference: every flag, reasoning levels, session memory, output layout,
236
- `--status` / `--continue`, the force prompt, Codex sandbox and network rules,
246
+ `--status` / `--stop` / `--continue`, the force prompt, Codex sandbox and network rules,
237
247
  and worktree mode.
238
248
 
239
249
  ## Contributing
@@ -63,7 +63,7 @@ from typing import Callable, Iterable, Optional
63
63
 
64
64
  # ---------- defaults ----------
65
65
 
66
- __version__ = "0.2.10"
66
+ __version__ = "0.2.11"
67
67
 
68
68
  DEFAULT_SENTINEL = "<<<LGTM>>>"
69
69
  DEFAULT_SANDBOX = "workspace-write"
@@ -120,6 +120,8 @@ STATUS_PHASES = {
120
120
  "finished",
121
121
  "unknown",
122
122
  }
123
+ STOP_GRACEFUL_SIGNAL = getattr(signal, "SIGUSR1", None)
124
+ STOP_IMMEDIATE_SIGNAL = signal.SIGTERM
123
125
 
124
126
 
125
127
  class AgentRunError(RuntimeError):
@@ -618,7 +620,8 @@ class VerifyResult:
618
620
  # ---------- active child process tracking ----------
619
621
 
620
622
  _ACTIVE_PROCS: set[subprocess.Popen] = set()
621
- _ACTIVE_PROCS_LOCK = threading.Lock()
623
+ # A signal handler can interrupt the main thread inside this lock and re-enter it.
624
+ _ACTIVE_PROCS_LOCK = threading.RLock()
622
625
 
623
626
 
624
627
  def _register_proc(proc: subprocess.Popen) -> None:
@@ -2092,6 +2095,41 @@ def _install_sigint(stop: StopFlag) -> None:
2092
2095
  signal.signal(signal.SIGINT, handler)
2093
2096
 
2094
2097
 
2098
+ def _install_run_stop_signal(stop: StopFlag) -> None:
2099
+ """Handle repeatable graceful stop requests without changing Ctrl-C."""
2100
+ if STOP_GRACEFUL_SIGNAL is None:
2101
+ return
2102
+
2103
+ def handler(signum, frame):
2104
+ if not stop.requested:
2105
+ print(
2106
+ "\n[duet] graceful stop requested — finishing the current "
2107
+ "turn, then stopping.",
2108
+ file=sys.stderr,
2109
+ )
2110
+ stop.request("run_stop")
2111
+
2112
+ signal.signal(STOP_GRACEFUL_SIGNAL, handler)
2113
+
2114
+
2115
+ def _install_sigterm(stop: StopFlag) -> None:
2116
+ """Make run-scoped immediate stops durable before this process exits."""
2117
+ def handler(signum, frame):
2118
+ if not stop.requested:
2119
+ print(
2120
+ "\n[duet] immediate stop requested — terminating the active "
2121
+ "child, then stopping.",
2122
+ file=sys.stderr,
2123
+ )
2124
+ stop.request("SIGTERM")
2125
+ # Each tracked child is its own process-group leader. This preserves
2126
+ # the existing descendant cleanup without signaling the supervisor's
2127
+ # process group or any other duet run.
2128
+ _terminate_active_processes(signal.SIGKILL)
2129
+
2130
+ signal.signal(signal.SIGTERM, handler)
2131
+
2132
+
2095
2133
  def _convergence_markers(text: str, sentinel: str) -> tuple[bool, bool]:
2096
2134
  """Return (sentinel_seen, rationale_seen), ignoring fenced code blocks."""
2097
2135
  sentinel_re = re.compile(rf"^\s*{re.escape(sentinel)}\s*$")
@@ -2424,6 +2462,7 @@ def _build_run_state(cfg: DuetConfig, *, turns_used: int, history: list,
2424
2462
  "codex_fast": cfg.codex_fast,
2425
2463
  "continue_from": cfg.continue_from,
2426
2464
  "duet_pid": os.getpid(),
2465
+ "duet_process_start": _CURRENT_PROCESS_START_IDENTITY,
2427
2466
  "error": error,
2428
2467
  }
2429
2468
  if cfg.recap:
@@ -2486,6 +2525,34 @@ def _launch_failure_state(
2486
2525
  return state
2487
2526
 
2488
2527
 
2528
+ def _requested_stop_state(
2529
+ cfg: DuetConfig,
2530
+ *,
2531
+ history: list,
2532
+ transcript_path: pathlib.Path,
2533
+ recap_path: pathlib.Path,
2534
+ state_path: pathlib.Path,
2535
+ wt_path: Optional[pathlib.Path] = None,
2536
+ wt_branch: Optional[str] = None,
2537
+ ) -> dict:
2538
+ """Persist an intentional stop that interrupted a setup subprocess."""
2539
+ state = _build_run_state(
2540
+ cfg,
2541
+ turns_used=len(history),
2542
+ history=history,
2543
+ finished_reason=FINISHED_FORCE_STOP,
2544
+ transcript_path=transcript_path,
2545
+ recap_path=recap_path,
2546
+ wt_path=wt_path,
2547
+ wt_branch=wt_branch,
2548
+ )
2549
+ _write_run_state(state_path, state)
2550
+ print(f"[duet] done. reason=force_stop. transcript: {transcript_path}")
2551
+ if cfg.recap:
2552
+ print(f"[duet] recap: {recap_path}")
2553
+ return state
2554
+
2555
+
2489
2556
  def _run_kickoff_command(
2490
2557
  cfg: DuetConfig,
2491
2558
  *,
@@ -2496,6 +2563,7 @@ def _run_kickoff_command(
2496
2563
  history: list,
2497
2564
  wt_path: Optional[pathlib.Path],
2498
2565
  wt_branch: Optional[str],
2566
+ stop: StopFlag,
2499
2567
  log: Callable[..., None],
2500
2568
  ) -> Optional[dict]:
2501
2569
  """Execute deferred task_from_cmd; return terminal state on failure."""
@@ -2519,6 +2587,17 @@ def _run_kickoff_command(
2519
2587
  cfg.task_from_cmd, cfg.cwd, cfg.per_turn_timeout, run_dir
2520
2588
  )
2521
2589
  except Exception as exc:
2590
+ if stop.requested and stop.reason == "SIGTERM":
2591
+ log("duet", "stop", "[duet] immediate stop requested", kind="stop")
2592
+ return _requested_stop_state(
2593
+ cfg,
2594
+ history=history,
2595
+ transcript_path=transcript_path,
2596
+ recap_path=recap_path,
2597
+ state_path=state_path,
2598
+ wt_path=wt_path,
2599
+ wt_branch=wt_branch,
2600
+ )
2522
2601
  message = str(exc)
2523
2602
  summary = _error_summary(message, type(exc).__name__)
2524
2603
  log(
@@ -2797,6 +2876,7 @@ def _derive_seed_or_failure(
2797
2876
  cfg: DuetConfig, *, run_dir: pathlib.Path, transcript_path: pathlib.Path,
2798
2877
  recap_path: pathlib.Path, state_path: pathlib.Path, history: list,
2799
2878
  wt_path: Optional[pathlib.Path], wt_branch: Optional[str],
2879
+ stop: StopFlag,
2800
2880
  log: Callable[..., None],
2801
2881
  ) -> tuple[Optional[str], Optional[dict]]:
2802
2882
  """Extract the opening seed message from the lead agent.
@@ -2826,6 +2906,18 @@ def _derive_seed_or_failure(
2826
2906
  )
2827
2907
  return derive_seed(cfg, run_dir=run_dir), None
2828
2908
  except Exception as e:
2909
+ if stop.requested and stop.reason == "SIGTERM":
2910
+ log("duet", "stop", "[duet] immediate stop requested", kind="stop")
2911
+ state = _requested_stop_state(
2912
+ cfg,
2913
+ history=history,
2914
+ transcript_path=transcript_path,
2915
+ recap_path=recap_path,
2916
+ state_path=state_path,
2917
+ wt_path=wt_path,
2918
+ wt_branch=wt_branch,
2919
+ )
2920
+ return None, state
2829
2921
  failure_reason = _agent_finished_reason(e)
2830
2922
  seed_agent = cfg.agents[0]
2831
2923
  reply = _agent_failure_block(
@@ -2921,6 +3013,8 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
2921
3013
 
2922
3014
  stop = StopFlag()
2923
3015
  _install_sigint(stop)
3016
+ _install_run_stop_signal(stop)
3017
+ _install_sigterm(stop)
2924
3018
  history: list[dict] = []
2925
3019
  transcript = ""
2926
3020
 
@@ -2963,7 +3057,7 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
2963
3057
  failure = _run_kickoff_command(
2964
3058
  cfg, run_dir=run_dir, transcript_path=transcript_path,
2965
3059
  recap_path=recap_path, state_path=state_path, history=history,
2966
- wt_path=None, wt_branch=None, log=log,
3060
+ wt_path=None, wt_branch=None, stop=stop, log=log,
2967
3061
  )
2968
3062
  if failure is not None:
2969
3063
  return context, failure
@@ -2978,6 +3072,16 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
2978
3072
  cfg, run_id, run_dir
2979
3073
  )
2980
3074
  except RunSetupError as exc:
3075
+ if stop.requested and stop.reason == "SIGTERM":
3076
+ log("duet", "stop", "[duet] immediate stop requested", kind="stop")
3077
+ state = _requested_stop_state(
3078
+ cfg,
3079
+ history=history,
3080
+ transcript_path=transcript_path,
3081
+ recap_path=recap_path,
3082
+ state_path=state_path,
3083
+ )
3084
+ return context, state
2981
3085
  state = _launch_failure_state(
2982
3086
  cfg, reason=FINISHED_SETUP_ERROR, message=str(exc), history=history,
2983
3087
  transcript_path=transcript_path, recap_path=recap_path,
@@ -3026,7 +3130,7 @@ def run_duet(cfg: DuetConfig) -> dict:
3026
3130
  seed, seed_failure_state = _derive_seed_or_failure(
3027
3131
  cfg, run_dir=run_dir, transcript_path=transcript_path,
3028
3132
  recap_path=recap_path, state_path=state_path, history=history,
3029
- wt_path=wt_path, wt_branch=wt_branch, log=log,
3133
+ wt_path=wt_path, wt_branch=wt_branch, stop=stop, log=log,
3030
3134
  )
3031
3135
  if seed_failure_state is not None:
3032
3136
  return seed_failure_state
@@ -3063,6 +3167,9 @@ def run_duet(cfg: DuetConfig) -> dict:
3063
3167
  else:
3064
3168
  print(result.reply)
3065
3169
 
3170
+ if stop.requested and stop.reason == "SIGTERM":
3171
+ finished_reason = FINISHED_FORCE_STOP
3172
+ break
3066
3173
  if result.failure_reason is not None:
3067
3174
  if _continue_after_timeout(result, may_continue, stop):
3068
3175
  print(f"[duet] turn {turn:02d} ({speaker.name}) timed out after "
@@ -3076,14 +3183,13 @@ def run_duet(cfg: DuetConfig) -> dict:
3076
3183
  continue
3077
3184
  finished_reason = result.failure_reason
3078
3185
  break
3186
+ if stop.requested:
3187
+ finished_reason = FINISHED_FORCE_STOP
3188
+ break
3079
3189
  previous_turn_timed_out = False
3080
3190
  if result.convergence_hit and previous_convergence_proposal:
3081
3191
  finished_reason = FINISHED_CONVERGED
3082
3192
  break
3083
- if stop.requested:
3084
- finished_reason = FINISHED_FORCE_STOP
3085
- break
3086
-
3087
3193
  last_msg = result.reply
3088
3194
  previous_convergence_proposal = result.convergence_hit
3089
3195
  speaker_idx = 1 - speaker_idx
@@ -3095,7 +3201,7 @@ def run_duet(cfg: DuetConfig) -> dict:
3095
3201
  finished_reason, forced_verify_state = ask_force(
3096
3202
  cfg, history, transcript_path, state_path,
3097
3203
  last_msg, speaker_idx, seen_first_turn,
3098
- finished_reason, wt_path, wt_branch
3204
+ finished_reason, wt_path, wt_branch, stop=stop,
3099
3205
  )
3100
3206
  if forced_verify_state is not None:
3101
3207
  last_verify_state = forced_verify_state
@@ -3227,8 +3333,11 @@ def ask_force(cfg: DuetConfig, history: list, transcript_path: pathlib.Path,
3227
3333
  state_path: pathlib.Path, last_msg: str, speaker_idx: int,
3228
3334
  seen_first_turn: dict, reason: str,
3229
3335
  wt_path: Optional[pathlib.Path] = None,
3230
- wt_branch: Optional[str] = None) -> tuple[str, Optional[dict]]:
3336
+ wt_branch: Optional[str] = None,
3337
+ stop: Optional[StopFlag] = None) -> tuple[str, Optional[dict]]:
3231
3338
  """Post-loop interactive prompt: human can push another turn or accept."""
3339
+ if stop is not None and stop.requested:
3340
+ return FINISHED_FORCE_STOP, None
3232
3341
  if not sys.stdin.isatty():
3233
3342
  return reason, None
3234
3343
  last_verify_state: Optional[dict] = None
@@ -3253,7 +3362,12 @@ def ask_force(cfg: DuetConfig, history: list, transcript_path: pathlib.Path,
3253
3362
  try:
3254
3363
  line = input("force> ").strip()
3255
3364
  except EOFError:
3256
- return reason, last_verify_state
3365
+ return (
3366
+ FINISHED_FORCE_STOP if stop is not None and stop.requested else reason,
3367
+ last_verify_state,
3368
+ )
3369
+ if stop is not None and stop.requested:
3370
+ return FINISHED_FORCE_STOP, last_verify_state
3257
3371
  if not line:
3258
3372
  return reason, last_verify_state
3259
3373
  next_speaker = cfg.agents[speaker_idx]
@@ -3274,21 +3388,24 @@ def ask_force(cfg: DuetConfig, history: list, transcript_path: pathlib.Path,
3274
3388
  last_verify_state=last_verify_state,
3275
3389
  )
3276
3390
  last_verify_state = result.last_verify_state
3391
+ forced_reason = result.failure_reason
3392
+ if stop is not None and stop.requested:
3393
+ forced_reason = FINISHED_FORCE_STOP
3277
3394
  # Persist each forced turn so a crash at the next force> prompt doesn't
3278
3395
  # lose it (the --status/--continue durability contract). finished_reason
3279
3396
  # stays None mid-loop (duet is alive at the prompt); run_duet writes the
3280
3397
  # final state with the real reason once ask_force returns.
3281
3398
  forced_state = _build_run_state(
3282
3399
  cfg, turns_used=len(history), history=history,
3283
- finished_reason=result.failure_reason,
3400
+ finished_reason=forced_reason,
3284
3401
  transcript_path=transcript_path,
3285
3402
  recap_path=transcript_path.parent / "recap.md",
3286
3403
  last_verify=last_verify_state, wt_path=wt_path, wt_branch=wt_branch,
3287
3404
  phase="awaiting_force",
3288
3405
  )
3289
3406
  _write_run_state(state_path, forced_state)
3290
- if result.failure_reason is not None:
3291
- return result.failure_reason, last_verify_state
3407
+ if forced_reason is not None:
3408
+ return forced_reason, last_verify_state
3292
3409
  last_msg = result.reply
3293
3410
  speaker_idx = 1 - speaker_idx
3294
3411
  reason = FINISHED_FORCED_CONTINUATION
@@ -3797,16 +3914,8 @@ def _proc_cmdline(pid: int) -> Optional[str]:
3797
3914
  return None
3798
3915
 
3799
3916
 
3800
- def _is_duet_process(pid: int) -> bool:
3801
- """True if `pid` is alive AND looks like a duet.py process (avoids stale-PID false positives)."""
3802
- if not _pid_alive(pid):
3803
- return False
3804
- cmdline = _proc_cmdline(pid)
3805
- if cmdline is None:
3806
- # Sandboxed macOS callers may be allowed to probe a sibling PID with
3807
- # signal 0 but denied `ps`. PID 1 is never duet; for other live PIDs,
3808
- # liveness is the best evidence available when identity is hidden.
3809
- return pid != 1
3917
+ def _looks_like_duet_cmdline(cmdline: str) -> bool:
3918
+ """Return whether a command line identifies a duet entry point."""
3810
3919
  try:
3811
3920
  argv = shlex.split(cmdline)
3812
3921
  except ValueError:
@@ -3824,6 +3933,90 @@ def _is_duet_process(pid: int) -> bool:
3824
3933
  return False
3825
3934
 
3826
3935
 
3936
+ def _proc_start_identity(pid: int) -> Optional[str]:
3937
+ """Return the strongest stable process-start identity available."""
3938
+ if sys.platform.startswith("linux"):
3939
+ try:
3940
+ stat = pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
3941
+ # The command name is parenthesized and can contain spaces. Fields
3942
+ # after its final ')' start at procfs field 3; starttime is field 22.
3943
+ fields = stat[stat.rfind(")") + 2:].split()
3944
+ return f"linux-proc-start:{fields[19]}"
3945
+ except (OSError, IndexError):
3946
+ return None
3947
+ if sys.platform == "darwin":
3948
+ try:
3949
+ import ctypes
3950
+
3951
+ class ProcBsdInfo(ctypes.Structure):
3952
+ _fields_ = [
3953
+ ("flags", ctypes.c_uint32),
3954
+ ("status", ctypes.c_uint32),
3955
+ ("xstatus", ctypes.c_uint32),
3956
+ ("pid", ctypes.c_uint32),
3957
+ ("ppid", ctypes.c_uint32),
3958
+ ("uid", ctypes.c_uint32),
3959
+ ("gid", ctypes.c_uint32),
3960
+ ("ruid", ctypes.c_uint32),
3961
+ ("rgid", ctypes.c_uint32),
3962
+ ("svuid", ctypes.c_uint32),
3963
+ ("svgid", ctypes.c_uint32),
3964
+ ("rfu", ctypes.c_uint32),
3965
+ ("comm", ctypes.c_char * 16),
3966
+ ("name", ctypes.c_char * 32),
3967
+ ("nfiles", ctypes.c_uint32),
3968
+ ("pgid", ctypes.c_uint32),
3969
+ ("pjobc", ctypes.c_uint32),
3970
+ ("e_tdev", ctypes.c_uint32),
3971
+ ("e_tpgid", ctypes.c_uint32),
3972
+ ("nice", ctypes.c_int32),
3973
+ ("start_sec", ctypes.c_uint64),
3974
+ ("start_usec", ctypes.c_uint64),
3975
+ ]
3976
+
3977
+ info = ProcBsdInfo()
3978
+ libproc = ctypes.CDLL("/usr/lib/libproc.dylib")
3979
+ size = ctypes.sizeof(info)
3980
+ read = libproc.proc_pidinfo(
3981
+ pid, 3, 0, ctypes.byref(info), size # PROC_PIDTBSDINFO
3982
+ )
3983
+ if read == size and info.pid == pid and info.start_sec:
3984
+ return f"darwin-proc-start:{info.start_sec}:{info.start_usec}"
3985
+ except (AttributeError, OSError, TypeError, ValueError):
3986
+ pass
3987
+ try:
3988
+ result = subprocess.run(
3989
+ ["ps", "-o", "lstart=", "-p", str(pid)],
3990
+ capture_output=True,
3991
+ text=True,
3992
+ encoding="utf-8",
3993
+ errors="replace",
3994
+ timeout=2,
3995
+ )
3996
+ started = " ".join(result.stdout.split())
3997
+ if result.returncode == 0 and started:
3998
+ return f"ps-lstart:{started}"
3999
+ except (subprocess.TimeoutExpired, OSError):
4000
+ pass
4001
+ return None
4002
+
4003
+
4004
+ _CURRENT_PROCESS_START_IDENTITY = _proc_start_identity(os.getpid())
4005
+
4006
+
4007
+ def _is_duet_process(pid: int) -> bool:
4008
+ """True if `pid` is alive AND looks like a duet.py process."""
4009
+ if not _pid_alive(pid):
4010
+ return False
4011
+ cmdline = _proc_cmdline(pid)
4012
+ if cmdline is None:
4013
+ # Status is observational. A sandbox can hide identity inspection, so
4014
+ # retain its prior liveness fallback. Mutating stop operations use the
4015
+ # stricter proof in `_validated_stop_target` instead.
4016
+ return pid != 1
4017
+ return _looks_like_duet_cmdline(cmdline)
4018
+
4019
+
3827
4020
  def _utc_rfc3339(value: float) -> str:
3828
4021
  timestamp = dt.datetime.fromtimestamp(value, tz=dt.timezone.utc)
3829
4022
  return timestamp.isoformat(timespec="seconds").replace("+00:00", "Z")
@@ -4243,6 +4436,141 @@ def _resolve_run_dir(arg: str, *, warn_ambiguous: bool = True) -> Optional[pathl
4243
4436
  return None
4244
4437
 
4245
4438
 
4439
+ def _bare_stop_run_candidates(arg: str) -> list[pathlib.Path]:
4440
+ """Return distinct directories addressed by one bare run id."""
4441
+ if "/" in arg or "\\" in arg or not _RUN_ID_RE.match(arg):
4442
+ return []
4443
+ candidates = [pathlib.Path(arg), *(
4444
+ root / arg for root in _default_list_paths()
4445
+ )]
4446
+ unique: list[pathlib.Path] = []
4447
+ seen: set[pathlib.Path] = set()
4448
+ for candidate in candidates:
4449
+ if not candidate.is_dir():
4450
+ continue
4451
+ try:
4452
+ resolved = candidate.resolve()
4453
+ except OSError:
4454
+ continue
4455
+ if resolved in seen:
4456
+ continue
4457
+ seen.add(resolved)
4458
+ unique.append(resolved)
4459
+ return unique
4460
+
4461
+
4462
+ class StopTargetError(RuntimeError):
4463
+ """A run cannot be safely resolved or proven before signaling."""
4464
+
4465
+ def __init__(self, message: str, exit_code: int) -> None:
4466
+ super().__init__(message)
4467
+ self.exit_code = exit_code
4468
+
4469
+
4470
+ def _read_stop_state(run_dir: pathlib.Path) -> dict:
4471
+ state_path = run_dir / "state.json"
4472
+ if not state_path.is_file():
4473
+ raise StopTargetError(f"missing state.json in {run_dir}", 3)
4474
+ try:
4475
+ state = json.loads(state_path.read_text(encoding="utf-8"))
4476
+ except (json.JSONDecodeError, OSError) as exc:
4477
+ raise StopTargetError(
4478
+ f"cannot read state.json in {run_dir}: {type(exc).__name__}", 3
4479
+ ) from exc
4480
+ if not isinstance(state, dict):
4481
+ raise StopTargetError(f"state.json root is not an object in {run_dir}", 3)
4482
+ return state
4483
+
4484
+
4485
+ def _validated_stop_target(arg: str) -> tuple[pathlib.Path, int]:
4486
+ """Resolve a run and prove its exact live supervisor identity.
4487
+
4488
+ Unlike status, this mutating path never falls back from hidden process
4489
+ identity to liveness alone. It also binds the PID to the process-start
4490
+ identity saved by that supervisor, which detects PID reuse.
4491
+ """
4492
+ bare_candidates = _bare_stop_run_candidates(arg)
4493
+ if len(bare_candidates) > 1:
4494
+ raise StopTargetError(
4495
+ f"run id {arg!r} is ambiguous across multiple roots; "
4496
+ "pass an explicit run directory path",
4497
+ 2,
4498
+ )
4499
+ run_dir = bare_candidates[0] if bare_candidates else _resolve_run_dir(arg)
4500
+ if run_dir is None:
4501
+ raise StopTargetError(f"run directory not found: {arg}", 3)
4502
+ state = _read_stop_state(run_dir)
4503
+ if state.get("finished_reason") is not None or state.get("phase") == "finished":
4504
+ raise StopTargetError(f"run is already terminal: {run_dir}", 2)
4505
+
4506
+ pid = _coerce_pid(state.get("duet_pid"))
4507
+ if pid is None:
4508
+ raise StopTargetError(f"run has an invalid duet_pid: {run_dir}", 2)
4509
+ if pid == 1:
4510
+ raise StopTargetError("refusing to signal PID 1", 2)
4511
+ if not _pid_alive(pid):
4512
+ raise StopTargetError(f"duet supervisor PID {pid} is stale", 2)
4513
+
4514
+ saved_start = state.get("duet_process_start")
4515
+ if not isinstance(saved_start, str) or not saved_start:
4516
+ raise StopTargetError(
4517
+ "run lacks a saved supervisor start identity; refusing unsafe stop", 2
4518
+ )
4519
+ live_start = _proc_start_identity(pid)
4520
+ if live_start is None:
4521
+ raise StopTargetError(
4522
+ f"cannot prove the start identity of duet supervisor PID {pid}", 2
4523
+ )
4524
+ if live_start != saved_start:
4525
+ raise StopTargetError(
4526
+ f"duet supervisor PID {pid} was reused; refusing to signal it", 2
4527
+ )
4528
+ cmdline = _proc_cmdline(pid)
4529
+ if cmdline is not None and not _looks_like_duet_cmdline(cmdline):
4530
+ raise StopTargetError(
4531
+ f"PID {pid} belongs to a foreign process; refusing to signal it", 2
4532
+ )
4533
+ return run_dir, pid
4534
+
4535
+
4536
+ def request_run_stop(arg: str, *, immediate: bool = False) -> int:
4537
+ """Signal one validated supervisor. Return 0=sent, 2=unsafe, 3=error."""
4538
+ try:
4539
+ run_dir, pid = _validated_stop_target(arg)
4540
+ # Re-read all identity evidence immediately before the signal. This
4541
+ # narrows the unavoidable check/signal race and catches state changes.
4542
+ checked_dir, checked_pid = _validated_stop_target(str(run_dir))
4543
+ if checked_dir != run_dir or checked_pid != pid:
4544
+ raise StopTargetError("run supervisor identity changed during stop", 2)
4545
+ if not immediate and STOP_GRACEFUL_SIGNAL is None:
4546
+ raise StopTargetError(
4547
+ "this platform has no run-scoped graceful stop signal", 2
4548
+ )
4549
+ stop_signal = (
4550
+ STOP_IMMEDIATE_SIGNAL if immediate else STOP_GRACEFUL_SIGNAL
4551
+ )
4552
+ os.kill(pid, stop_signal)
4553
+ except StopTargetError as exc:
4554
+ print(f"[duet] stop refused: {exc}", file=sys.stderr)
4555
+ return exc.exit_code
4556
+ except ProcessLookupError:
4557
+ print(
4558
+ f"[duet] stop refused: duet supervisor PID {pid} became stale",
4559
+ file=sys.stderr,
4560
+ )
4561
+ return 2
4562
+ except PermissionError:
4563
+ print(
4564
+ f"[duet] stop refused: permission denied for duet supervisor PID {pid}",
4565
+ file=sys.stderr,
4566
+ )
4567
+ return 2
4568
+
4569
+ mode = "immediate" if immediate else "graceful"
4570
+ print(f"[duet] {mode} stop requested for {run_dir} (supervisor PID {pid})")
4571
+ return 0
4572
+
4573
+
4246
4574
  def _load_run_state(run_dir: pathlib.Path,
4247
4575
  parser: argparse.ArgumentParser,
4248
4576
  option_name: str) -> dict:
@@ -4627,6 +4955,26 @@ def print_runs_list(explicit_path: Optional[pathlib.Path]) -> int:
4627
4955
  return 0
4628
4956
 
4629
4957
 
4958
+ def _add_control_arguments(parser: argparse.ArgumentParser) -> None:
4959
+ parser.add_argument(
4960
+ "--continue", metavar="RUN_DIR_OR_ID", dest="continue_run",
4961
+ help="start a new run from an existing run's state.json: restore "
4962
+ "agents/session ids, reuse its worktree when available, and send "
4963
+ "the next agent a continuation kickoff. Seed flags can add guidance.",
4964
+ )
4965
+ parser.add_argument(
4966
+ "--stop", metavar="RUN_DIR_OR_ID", default=None,
4967
+ help="request a graceful stop for one live run. Resolves like --status, "
4968
+ "proves the exact supervisor PID and process-start identity, then "
4969
+ "signals only that PID. The current child turn can finish.",
4970
+ )
4971
+ parser.add_argument(
4972
+ "--immediate", action="store_true",
4973
+ help="with --stop, terminate that run's active child process group and "
4974
+ "finish the run with reason=force_stop",
4975
+ )
4976
+
4977
+
4630
4978
  def _build_arg_parser() -> argparse.ArgumentParser:
4631
4979
  ap = argparse.ArgumentParser(
4632
4980
  description="duet — two CLI agents in conversation, with per-agent session memory.")
@@ -4639,11 +4987,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
4639
4987
  "its latest message and feed it to the partner agent.")
4640
4988
  ap.add_argument("--resume-codex", metavar="SESSION_ID",
4641
4989
  help="(advanced) seed codex with an existing session id.")
4642
- ap.add_argument("--continue", metavar="RUN_DIR_OR_ID", dest="continue_run",
4643
- help="start a new run from an existing run's state.json: "
4644
- "restore agents/session ids, reuse its worktree when "
4645
- "available, and send the next agent a continuation kickoff. "
4646
- "--task/--kickoff/--task-from-cmd may add optional guidance.")
4990
+ _add_control_arguments(ap)
4647
4991
  ap.add_argument("--task", help="task description, @file, or @- stdin "
4648
4992
  "(used if no --resume-* and no --kickoff)")
4649
4993
  ap.add_argument("--kickoff", help="explicit first message, @file, or @- stdin "
@@ -5154,6 +5498,15 @@ def main() -> int:
5154
5498
  ap = _build_arg_parser()
5155
5499
  args = ap.parse_args()
5156
5500
 
5501
+ if args.immediate and args.stop is None:
5502
+ ap.error("--immediate is only valid with --stop")
5503
+ if args.stop is not None:
5504
+ if args.status or args.list_runs is not None or args.continue_run:
5505
+ ap.error("--stop cannot be combined with --status, --list, or --continue")
5506
+ if args.json_output:
5507
+ ap.error("--json is only valid with --status")
5508
+ return request_run_stop(args.stop, immediate=args.immediate)
5509
+
5157
5510
  # `--status` is read-only: print run health and exit. Skip everything below.
5158
5511
  if args.status:
5159
5512
  return print_run_status_safe(args.status, json_output=args.json_output)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.10
3
+ Version: 0.2.11
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -235,9 +235,19 @@ stop, duet opens a `force>` prompt so you can push another round.
235
235
  Every run writes a directory with `transcript.md`, `state.json`, per-turn
236
236
  stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
237
237
  `duet --status <run-id> --json` (or omit `--json` for the human view), list runs
238
- with `duet --list`, and start a fresh run
238
+ with `duet --list`, stop one exact live run with `duet --stop <run-id>`, and
239
+ start a fresh run
239
240
  from saved state with `duet --continue <run> --task "next thing"`.
240
241
 
242
+ `--stop` is graceful. It lets the current agent turn finish before duet records
243
+ `force_stop`. Use `--stop <run-id> --immediate` to terminate that run's active
244
+ child and record `force_stop` now. Both forms validate the saved supervisor PID
245
+ and process-start identity. They never use a process-name match or signal a
246
+ different duet run.
247
+
248
+ If status remains `awaiting_force`, press Enter in the original terminal.
249
+ Some platforms do not wake the blocking `force>` input after an external signal.
250
+
241
251
  - **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
242
252
  - **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
243
253
  - **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
@@ -254,7 +264,7 @@ a deep-reasoning turn is stuck.
254
264
 
255
265
  [docs/USAGE.md](https://github.com/volkan/duet/blob/main/docs/USAGE.md) is the
256
266
  full reference: every flag, reasoning levels, session memory, output layout,
257
- `--status` / `--continue`, the force prompt, Codex sandbox and network rules,
267
+ `--status` / `--stop` / `--continue`, the force prompt, Codex sandbox and network rules,
258
268
  and worktree mode.
259
269
 
260
270
  ## Contributing
@@ -10,4 +10,5 @@ duet_cli.egg-info/requires.txt
10
10
  duet_cli.egg-info/top_level.txt
11
11
  tests/test_bump_release_version.py
12
12
  tests/test_control_plane.py
13
- tests/test_duet.py
13
+ tests/test_duet.py
14
+ tests/test_stop_control_plane.py
@@ -224,6 +224,39 @@ class TestRunInfoAndLaunchFailures(unittest.TestCase):
224
224
  contextlib.redirect_stderr(io.StringIO()):
225
225
  duet._setup_run_worktree(cfg, "run", root)
226
226
 
227
+ def test_immediate_stop_during_worktree_setup_is_force_stop(self) -> None:
228
+ with tempfile.TemporaryDirectory() as raw:
229
+ root = pathlib.Path(raw).resolve()
230
+ info = root / "run.json"
231
+ cfg = self._cfg(root, info, task="x", worktree=True)
232
+ installed: dict[str, duet.StopFlag] = {}
233
+
234
+ def capture_stop(stop: duet.StopFlag) -> None:
235
+ installed["stop"] = stop
236
+
237
+ def interrupted_setup(*_args):
238
+ installed["stop"].request("SIGTERM")
239
+ raise duet.RunSetupError("interrupted setup")
240
+
241
+ with mock.patch.object(duet, "_install_sigint"), \
242
+ mock.patch.object(
243
+ duet, "_install_sigterm", side_effect=capture_stop
244
+ ), \
245
+ mock.patch.object(
246
+ duet, "_setup_run_worktree", side_effect=interrupted_setup
247
+ ), \
248
+ mock.patch.object(duet, "_register_run_in_home_index"), \
249
+ contextlib.redirect_stdout(io.StringIO()), \
250
+ contextlib.redirect_stderr(io.StringIO()):
251
+ state = duet.run_duet(cfg)
252
+
253
+ self.assertEqual(state["phase"], "finished")
254
+ self.assertEqual(state["finished_reason"], "force_stop")
255
+ saved = json.loads(
256
+ pathlib.Path(json.loads(info.read_text())["state_path"]).read_text()
257
+ )
258
+ self.assertEqual(saved["finished_reason"], "force_stop")
259
+
227
260
  def test_kickoff_failure_is_terminal_but_status_stays_secret_minimized(self) -> None:
228
261
  with tempfile.TemporaryDirectory() as raw:
229
262
  root = pathlib.Path(raw).resolve()
@@ -459,6 +492,169 @@ class TestStatusSchema(unittest.TestCase):
459
492
  self.assertIn("JSONDecodeError", snapshot["error"])
460
493
 
461
494
 
495
+ class TestRunScopedStopValidation(unittest.TestCase):
496
+ def _run(self, root: pathlib.Path, **overrides) -> pathlib.Path:
497
+ run = root / "20260719-120000"
498
+ run.mkdir()
499
+ state = {
500
+ "phase": "turn_running",
501
+ "finished_reason": None,
502
+ "duet_pid": 123,
503
+ "duet_process_start": "start-123",
504
+ }
505
+ state.update(overrides)
506
+ (run / "state.json").write_text(json.dumps(state), encoding="utf-8")
507
+ return run
508
+
509
+ def test_invalid_pid_is_refused_without_signal(self) -> None:
510
+ with tempfile.TemporaryDirectory() as raw:
511
+ run = self._run(pathlib.Path(raw), duet_pid="invalid")
512
+ with mock.patch.object(duet.os, "kill") as kill, \
513
+ contextlib.redirect_stderr(io.StringIO()):
514
+ self.assertEqual(duet.request_run_stop(str(run)), 2)
515
+ kill.assert_not_called()
516
+
517
+ def test_ambiguous_bare_run_id_is_refused_without_signal(self) -> None:
518
+ with tempfile.TemporaryDirectory() as raw:
519
+ root = pathlib.Path(raw)
520
+ run_id = "20260719-120000"
521
+ roots = [root / "one", root / "two"]
522
+ for candidate_root in roots:
523
+ (candidate_root / run_id).mkdir(parents=True)
524
+ with mock.patch.object(
525
+ duet, "_default_list_paths", return_value=roots), \
526
+ mock.patch.object(duet.os, "kill") as kill, \
527
+ contextlib.redirect_stderr(io.StringIO()) as stderr:
528
+ self.assertEqual(duet.request_run_stop(run_id), 2)
529
+ self.assertIn("ambiguous", stderr.getvalue())
530
+ self.assertIn("explicit run directory path", stderr.getvalue())
531
+ kill.assert_not_called()
532
+
533
+ def test_stale_pid_is_refused_without_signal(self) -> None:
534
+ with tempfile.TemporaryDirectory() as raw:
535
+ run = self._run(pathlib.Path(raw))
536
+ with mock.patch.object(duet, "_pid_alive", return_value=False), \
537
+ mock.patch.object(duet.os, "kill") as kill, \
538
+ contextlib.redirect_stderr(io.StringIO()):
539
+ self.assertEqual(duet.request_run_stop(str(run)), 2)
540
+ kill.assert_not_called()
541
+
542
+ def test_terminal_run_is_refused_without_signal(self) -> None:
543
+ with tempfile.TemporaryDirectory() as raw:
544
+ run = self._run(
545
+ pathlib.Path(raw), phase="finished", finished_reason="max_turns"
546
+ )
547
+ with mock.patch.object(duet.os, "kill") as kill, \
548
+ contextlib.redirect_stderr(io.StringIO()):
549
+ self.assertEqual(duet.request_run_stop(str(run)), 2)
550
+ kill.assert_not_called()
551
+
552
+ def test_pid_one_is_refused_without_signal(self) -> None:
553
+ with tempfile.TemporaryDirectory() as raw:
554
+ run = self._run(pathlib.Path(raw), duet_pid=1)
555
+ with mock.patch.object(duet.os, "kill") as kill, \
556
+ contextlib.redirect_stderr(io.StringIO()):
557
+ self.assertEqual(duet.request_run_stop(str(run)), 2)
558
+ kill.assert_not_called()
559
+
560
+ def test_reused_pid_is_refused_without_signal(self) -> None:
561
+ with tempfile.TemporaryDirectory() as raw:
562
+ run = self._run(pathlib.Path(raw))
563
+ with mock.patch.object(duet, "_pid_alive", return_value=True), \
564
+ mock.patch.object(
565
+ duet, "_proc_start_identity", return_value="new-start"
566
+ ), \
567
+ mock.patch.object(duet.os, "kill") as kill, \
568
+ contextlib.redirect_stderr(io.StringIO()):
569
+ self.assertEqual(duet.request_run_stop(str(run)), 2)
570
+ kill.assert_not_called()
571
+
572
+ def test_foreign_process_is_refused_without_signal(self) -> None:
573
+ with tempfile.TemporaryDirectory() as raw:
574
+ run = self._run(pathlib.Path(raw))
575
+ with mock.patch.object(duet, "_pid_alive", return_value=True), \
576
+ mock.patch.object(
577
+ duet, "_proc_start_identity", return_value="start-123"
578
+ ), \
579
+ mock.patch.object(
580
+ duet, "_proc_cmdline", return_value="python worker.py"
581
+ ), \
582
+ mock.patch.object(duet.os, "kill") as kill, \
583
+ contextlib.redirect_stderr(io.StringIO()):
584
+ self.assertEqual(duet.request_run_stop(str(run)), 2)
585
+ kill.assert_not_called()
586
+
587
+ def test_missing_start_identity_is_refused_without_signal(self) -> None:
588
+ with tempfile.TemporaryDirectory() as raw:
589
+ run = self._run(pathlib.Path(raw), duet_process_start=None)
590
+ with mock.patch.object(duet, "_pid_alive", return_value=True), \
591
+ mock.patch.object(duet.os, "kill") as kill, \
592
+ contextlib.redirect_stderr(io.StringIO()):
593
+ self.assertEqual(duet.request_run_stop(str(run)), 2)
594
+ kill.assert_not_called()
595
+
596
+ def test_graceful_and_immediate_signal_only_exact_supervisor_pid(self) -> None:
597
+ for immediate, expected_signal in (
598
+ (False, duet.STOP_GRACEFUL_SIGNAL),
599
+ (True, duet.signal.SIGTERM)):
600
+ with self.subTest(immediate=immediate), \
601
+ tempfile.TemporaryDirectory() as raw:
602
+ run = self._run(pathlib.Path(raw))
603
+ with mock.patch.object(duet, "_pid_alive", return_value=True), \
604
+ mock.patch.object(
605
+ duet, "_proc_start_identity", return_value="start-123"
606
+ ), \
607
+ mock.patch.object(duet, "_proc_cmdline", return_value=None), \
608
+ mock.patch.object(duet.os, "kill") as kill, \
609
+ contextlib.redirect_stdout(io.StringIO()):
610
+ self.assertEqual(
611
+ duet.request_run_stop(str(run), immediate=immediate), 0
612
+ )
613
+ kill.assert_called_once_with(123, expected_signal)
614
+
615
+ def test_repeated_run_stop_signal_does_not_hard_exit(self) -> None:
616
+ if duet.STOP_GRACEFUL_SIGNAL is None:
617
+ self.skipTest("platform has no run-scoped graceful stop signal")
618
+ stop = duet.StopFlag()
619
+ with mock.patch.object(duet.signal, "signal") as install, \
620
+ mock.patch.object(duet.os, "_exit") as hard_exit, \
621
+ contextlib.redirect_stderr(io.StringIO()):
622
+ duet._install_run_stop_signal(stop)
623
+ handler = install.call_args.args[1]
624
+ handler(duet.STOP_GRACEFUL_SIGNAL, None)
625
+ handler(duet.STOP_GRACEFUL_SIGNAL, None)
626
+ self.assertTrue(stop.requested)
627
+ self.assertEqual(stop.reason, "run_stop")
628
+ hard_exit.assert_not_called()
629
+
630
+ def test_force_prompt_records_stop_after_tty_input_unblocks(self) -> None:
631
+ with tempfile.TemporaryDirectory() as raw:
632
+ root = pathlib.Path(raw)
633
+ cfg = duet.DuetConfig(cwd=root, agents=_agents(), task="x")
634
+ stop = duet.StopFlag()
635
+
636
+ def unblock_prompt(_prompt: str) -> str:
637
+ stop.request("SIGTERM")
638
+ return ""
639
+
640
+ with mock.patch.object(duet.sys.stdin, "isatty", return_value=True), \
641
+ mock.patch("builtins.input", side_effect=unblock_prompt), \
642
+ contextlib.redirect_stdout(io.StringIO()):
643
+ reason, _ = duet.ask_force(
644
+ cfg,
645
+ [],
646
+ root / "transcript.md",
647
+ root / "state.json",
648
+ "last message",
649
+ 1,
650
+ {agent.name: False for agent in cfg.agents},
651
+ "max_turns",
652
+ stop=stop,
653
+ )
654
+
655
+ self.assertEqual(reason, "force_stop")
656
+
657
+
462
658
  class TestContinueWorktreeOverrides(unittest.TestCase):
463
659
  def _write_prior_run(
464
660
  self,
@@ -619,7 +815,7 @@ class TestTimeoutContinueLoop(unittest.TestCase):
619
815
 
620
816
  def skip_force(_cfg, _history, _transcript_path, _state_path,
621
817
  _last_msg, _speaker_idx, _seen_first_turn, reason,
622
- _wt_path=None, _wt_branch=None):
818
+ _wt_path=None, _wt_branch=None, **_kwargs):
623
819
  return reason, None
624
820
 
625
821
  err = io.StringIO()
@@ -0,0 +1,200 @@
1
+ """Process-level regression tests for run-scoped shutdown."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import pathlib
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ import unittest
13
+
14
+ import duet
15
+
16
+
17
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
18
+ DUET = ROOT / "duet.py"
19
+
20
+
21
+ def _wait_for(predicate, message: str, timeout: float = 8.0):
22
+ deadline = time.monotonic() + timeout
23
+ while time.monotonic() < deadline:
24
+ value = predicate()
25
+ if value:
26
+ return value
27
+ time.sleep(0.02)
28
+ raise AssertionError(message)
29
+
30
+
31
+ def _read_json_if_ready(path: pathlib.Path):
32
+ try:
33
+ return json.loads(path.read_text(encoding="utf-8"))
34
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
35
+ return None
36
+
37
+
38
+ def _active_child_pid(run_dir: pathlib.Path):
39
+ for pid_file in run_dir.glob("turn-*.pid"):
40
+ try:
41
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
42
+ except (OSError, ValueError):
43
+ continue
44
+ if duet._pid_alive(pid):
45
+ return pid
46
+ return None
47
+
48
+
49
+ @unittest.skipIf(sys.platform == "win32", "requires POSIX signals")
50
+ class TestRunScopedStop(unittest.TestCase):
51
+ def test_immediate_stop_isolated_to_selected_live_run(self) -> None:
52
+ with tempfile.TemporaryDirectory() as raw:
53
+ root = pathlib.Path(raw).resolve()
54
+ bin_dir = root / "bin"
55
+ bin_dir.mkdir()
56
+ fake_backend = bin_dir / "claude"
57
+ fake_backend.write_text(
58
+ "#!/usr/bin/env python3\n"
59
+ "import time\n"
60
+ "while True:\n"
61
+ " time.sleep(1)\n",
62
+ encoding="utf-8",
63
+ )
64
+ fake_backend.chmod(0o755)
65
+ env = os.environ.copy()
66
+ env["PATH"] = str(bin_dir) + os.pathsep + env.get("PATH", "")
67
+
68
+ supervisors: list[subprocess.Popen] = []
69
+ run_dirs: list[pathlib.Path] = []
70
+ child_pids: list[int] = []
71
+ supervisor_starts: list[str] = []
72
+ child_starts: list[str] = []
73
+ try:
74
+ for label in ("a", "b"):
75
+ work = root / label
76
+ work.mkdir()
77
+ info = root / f"{label}.json"
78
+ proc = subprocess.Popen(
79
+ [
80
+ sys.executable, str(DUET),
81
+ "--cwd", str(work),
82
+ "--runs-dir", str(work / "runs"),
83
+ "--run-info-file", str(info),
84
+ "--lead", "claude:planner",
85
+ "--partner", "claude:coder",
86
+ "--task", "wait for shutdown",
87
+ "--turns", "2",
88
+ "--timeout", "60",
89
+ "--no-worktree",
90
+ "--quiet",
91
+ ],
92
+ stdin=subprocess.DEVNULL,
93
+ stdout=subprocess.DEVNULL,
94
+ stderr=subprocess.DEVNULL,
95
+ env=env,
96
+ start_new_session=True,
97
+ )
98
+ supervisors.append(proc)
99
+ supervisor_starts.append(_wait_for(
100
+ lambda p=proc.pid: duet._proc_start_identity(p),
101
+ f"cannot identify supervisor for run {label}",
102
+ ))
103
+ launch = _wait_for(
104
+ lambda p=info: _read_json_if_ready(p),
105
+ f"run {label} did not publish run info",
106
+ )
107
+ run_dir = pathlib.Path(launch["run_dir"])
108
+ run_dirs.append(run_dir)
109
+ child_pid = _wait_for(
110
+ lambda p=run_dir: _active_child_pid(p),
111
+ f"run {label} did not start its backend child",
112
+ )
113
+ child_pids.append(child_pid)
114
+ child_starts.append(_wait_for(
115
+ lambda p=child_pid: duet._proc_start_identity(p),
116
+ f"cannot identify backend child for run {label}",
117
+ ))
118
+
119
+ for _ in range(2):
120
+ graceful = subprocess.run(
121
+ [
122
+ sys.executable, str(DUET),
123
+ "--stop", str(run_dirs[0]),
124
+ ],
125
+ capture_output=True,
126
+ text=True,
127
+ encoding="utf-8",
128
+ errors="replace",
129
+ env=env,
130
+ timeout=10,
131
+ )
132
+ self.assertEqual(
133
+ graceful.returncode, 0,
134
+ graceful.stdout + graceful.stderr,
135
+ )
136
+ time.sleep(0.1)
137
+ self.assertIsNone(
138
+ supervisors[0].poll(),
139
+ "repeated graceful stop hard-exited the supervisor",
140
+ )
141
+
142
+ stopped = subprocess.run(
143
+ [
144
+ sys.executable, str(DUET),
145
+ "--stop", str(run_dirs[0]),
146
+ "--immediate",
147
+ ],
148
+ capture_output=True,
149
+ text=True,
150
+ encoding="utf-8",
151
+ errors="replace",
152
+ env=env,
153
+ timeout=10,
154
+ )
155
+ self.assertEqual(
156
+ stopped.returncode, 0, stopped.stdout + stopped.stderr
157
+ )
158
+ _wait_for(
159
+ lambda: supervisors[0].poll() is not None,
160
+ "selected supervisor did not exit",
161
+ )
162
+ state_a = _wait_for(
163
+ lambda: _read_json_if_ready(run_dirs[0] / "state.json"),
164
+ "selected run did not retain readable state",
165
+ )
166
+ self.assertEqual(state_a["phase"], "finished")
167
+ self.assertEqual(state_a["finished_reason"], "force_stop")
168
+ self.assertFalse(duet._pid_alive(child_pids[0]))
169
+
170
+ self.assertIsNone(supervisors[1].poll())
171
+ self.assertTrue(duet._pid_alive(supervisors[1].pid))
172
+ self.assertTrue(duet._pid_alive(child_pids[1]))
173
+ finally:
174
+ # Cleanup is exact and fail-closed. Never match process names.
175
+ for pid, started in zip(child_pids, child_starts):
176
+ self._kill_validated(pid, str(fake_backend), started)
177
+ for proc, run_dir, started in zip(
178
+ supervisors, run_dirs, supervisor_starts):
179
+ self._kill_validated(proc.pid, str(run_dir.parent), started)
180
+ try:
181
+ proc.wait(timeout=3)
182
+ except subprocess.TimeoutExpired:
183
+ self._kill_validated(
184
+ proc.pid, str(run_dir.parent), started, signal.SIGKILL
185
+ )
186
+ proc.wait(timeout=3)
187
+
188
+ def _kill_validated(self, pid: int, expected: str, saved_start: str,
189
+ sig: int = signal.SIGTERM) -> None:
190
+ if not duet._pid_alive(pid):
191
+ return
192
+ self.assertEqual(duet._proc_start_identity(pid), saved_start)
193
+ command = duet._proc_cmdline(pid)
194
+ if command is not None:
195
+ self.assertIn(expected, command)
196
+ os.kill(pid, sig)
197
+
198
+
199
+ if __name__ == "__main__":
200
+ unittest.main()
File without changes
File without changes
File without changes
File without changes