duet-cli 0.2.10__tar.gz → 0.2.12__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.12
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.12"
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):
@@ -272,16 +274,17 @@ CLAUDE_REASONING_MAP = {
272
274
  }
273
275
 
274
276
  # Codex CLI takes a config override `-c model_reasoning_effort=<value>`.
275
- # Its accepted values, lowest→highest, are: minimal, low, medium, high, xhigh.
276
- # We also map duet's `max` alias to Codex's `xhigh` because Codex does not
277
- # document a separate `max` effort value.
277
+ # Pass every duet level through unchanged. Codex support is model-specific, so
278
+ # its model resolver must reject an unsupported value instead of duet silently
279
+ # changing the requested effort. Always emit an explicit requested value: a
280
+ # model's native default is not necessarily `medium`.
278
281
  CODEX_REASONING_MAP = {
279
282
  "minimal": "minimal",
280
283
  "low": "low",
281
284
  "medium": "medium",
282
285
  "high": "high",
283
286
  "xhigh": "xhigh",
284
- "max": "xhigh",
287
+ "max": "max",
285
288
  }
286
289
 
287
290
  # Gemini CLI does not expose a documented reasoning-effort flag. Keep the duet
@@ -618,7 +621,8 @@ class VerifyResult:
618
621
  # ---------- active child process tracking ----------
619
622
 
620
623
  _ACTIVE_PROCS: set[subprocess.Popen] = set()
621
- _ACTIVE_PROCS_LOCK = threading.Lock()
624
+ # A signal handler can interrupt the main thread inside this lock and re-enter it.
625
+ _ACTIVE_PROCS_LOCK = threading.RLock()
622
626
 
623
627
 
624
628
  def _register_proc(proc: subprocess.Popen) -> None:
@@ -1421,10 +1425,7 @@ def call_codex(agent: Agent, system_prompt: str, message: str,
1421
1425
  reasoning_args: list[str] = []
1422
1426
  if effective:
1423
1427
  codex_value = backend_adapter("codex").reasoning_map.get(effective, effective)
1424
- # `medium` is Codex's default; only override when we actually want a
1425
- # different effort level.
1426
- if codex_value != "medium":
1427
- reasoning_args = ["-c", f"model_reasoning_effort={codex_value}"]
1428
+ reasoning_args = ["-c", f"model_reasoning_effort={codex_value}"]
1428
1429
  if fast:
1429
1430
  # Concise reasoning summaries cut output volume and time-to-first-token
1430
1431
  # on Codex turns. Pairs with low effort above; together they're the
@@ -2092,6 +2093,41 @@ def _install_sigint(stop: StopFlag) -> None:
2092
2093
  signal.signal(signal.SIGINT, handler)
2093
2094
 
2094
2095
 
2096
+ def _install_run_stop_signal(stop: StopFlag) -> None:
2097
+ """Handle repeatable graceful stop requests without changing Ctrl-C."""
2098
+ if STOP_GRACEFUL_SIGNAL is None:
2099
+ return
2100
+
2101
+ def handler(signum, frame):
2102
+ if not stop.requested:
2103
+ print(
2104
+ "\n[duet] graceful stop requested — finishing the current "
2105
+ "turn, then stopping.",
2106
+ file=sys.stderr,
2107
+ )
2108
+ stop.request("run_stop")
2109
+
2110
+ signal.signal(STOP_GRACEFUL_SIGNAL, handler)
2111
+
2112
+
2113
+ def _install_sigterm(stop: StopFlag) -> None:
2114
+ """Make run-scoped immediate stops durable before this process exits."""
2115
+ def handler(signum, frame):
2116
+ if not stop.requested:
2117
+ print(
2118
+ "\n[duet] immediate stop requested — terminating the active "
2119
+ "child, then stopping.",
2120
+ file=sys.stderr,
2121
+ )
2122
+ stop.request("SIGTERM")
2123
+ # Each tracked child is its own process-group leader. This preserves
2124
+ # the existing descendant cleanup without signaling the supervisor's
2125
+ # process group or any other duet run.
2126
+ _terminate_active_processes(signal.SIGKILL)
2127
+
2128
+ signal.signal(signal.SIGTERM, handler)
2129
+
2130
+
2095
2131
  def _convergence_markers(text: str, sentinel: str) -> tuple[bool, bool]:
2096
2132
  """Return (sentinel_seen, rationale_seen), ignoring fenced code blocks."""
2097
2133
  sentinel_re = re.compile(rf"^\s*{re.escape(sentinel)}\s*$")
@@ -2424,6 +2460,7 @@ def _build_run_state(cfg: DuetConfig, *, turns_used: int, history: list,
2424
2460
  "codex_fast": cfg.codex_fast,
2425
2461
  "continue_from": cfg.continue_from,
2426
2462
  "duet_pid": os.getpid(),
2463
+ "duet_process_start": _CURRENT_PROCESS_START_IDENTITY,
2427
2464
  "error": error,
2428
2465
  }
2429
2466
  if cfg.recap:
@@ -2486,6 +2523,34 @@ def _launch_failure_state(
2486
2523
  return state
2487
2524
 
2488
2525
 
2526
+ def _requested_stop_state(
2527
+ cfg: DuetConfig,
2528
+ *,
2529
+ history: list,
2530
+ transcript_path: pathlib.Path,
2531
+ recap_path: pathlib.Path,
2532
+ state_path: pathlib.Path,
2533
+ wt_path: Optional[pathlib.Path] = None,
2534
+ wt_branch: Optional[str] = None,
2535
+ ) -> dict:
2536
+ """Persist an intentional stop that interrupted a setup subprocess."""
2537
+ state = _build_run_state(
2538
+ cfg,
2539
+ turns_used=len(history),
2540
+ history=history,
2541
+ finished_reason=FINISHED_FORCE_STOP,
2542
+ transcript_path=transcript_path,
2543
+ recap_path=recap_path,
2544
+ wt_path=wt_path,
2545
+ wt_branch=wt_branch,
2546
+ )
2547
+ _write_run_state(state_path, state)
2548
+ print(f"[duet] done. reason=force_stop. transcript: {transcript_path}")
2549
+ if cfg.recap:
2550
+ print(f"[duet] recap: {recap_path}")
2551
+ return state
2552
+
2553
+
2489
2554
  def _run_kickoff_command(
2490
2555
  cfg: DuetConfig,
2491
2556
  *,
@@ -2496,6 +2561,7 @@ def _run_kickoff_command(
2496
2561
  history: list,
2497
2562
  wt_path: Optional[pathlib.Path],
2498
2563
  wt_branch: Optional[str],
2564
+ stop: StopFlag,
2499
2565
  log: Callable[..., None],
2500
2566
  ) -> Optional[dict]:
2501
2567
  """Execute deferred task_from_cmd; return terminal state on failure."""
@@ -2519,6 +2585,17 @@ def _run_kickoff_command(
2519
2585
  cfg.task_from_cmd, cfg.cwd, cfg.per_turn_timeout, run_dir
2520
2586
  )
2521
2587
  except Exception as exc:
2588
+ if stop.requested and stop.reason == "SIGTERM":
2589
+ log("duet", "stop", "[duet] immediate stop requested", kind="stop")
2590
+ return _requested_stop_state(
2591
+ cfg,
2592
+ history=history,
2593
+ transcript_path=transcript_path,
2594
+ recap_path=recap_path,
2595
+ state_path=state_path,
2596
+ wt_path=wt_path,
2597
+ wt_branch=wt_branch,
2598
+ )
2522
2599
  message = str(exc)
2523
2600
  summary = _error_summary(message, type(exc).__name__)
2524
2601
  log(
@@ -2797,6 +2874,7 @@ def _derive_seed_or_failure(
2797
2874
  cfg: DuetConfig, *, run_dir: pathlib.Path, transcript_path: pathlib.Path,
2798
2875
  recap_path: pathlib.Path, state_path: pathlib.Path, history: list,
2799
2876
  wt_path: Optional[pathlib.Path], wt_branch: Optional[str],
2877
+ stop: StopFlag,
2800
2878
  log: Callable[..., None],
2801
2879
  ) -> tuple[Optional[str], Optional[dict]]:
2802
2880
  """Extract the opening seed message from the lead agent.
@@ -2826,6 +2904,18 @@ def _derive_seed_or_failure(
2826
2904
  )
2827
2905
  return derive_seed(cfg, run_dir=run_dir), None
2828
2906
  except Exception as e:
2907
+ if stop.requested and stop.reason == "SIGTERM":
2908
+ log("duet", "stop", "[duet] immediate stop requested", kind="stop")
2909
+ state = _requested_stop_state(
2910
+ cfg,
2911
+ history=history,
2912
+ transcript_path=transcript_path,
2913
+ recap_path=recap_path,
2914
+ state_path=state_path,
2915
+ wt_path=wt_path,
2916
+ wt_branch=wt_branch,
2917
+ )
2918
+ return None, state
2829
2919
  failure_reason = _agent_finished_reason(e)
2830
2920
  seed_agent = cfg.agents[0]
2831
2921
  reply = _agent_failure_block(
@@ -2921,6 +3011,8 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
2921
3011
 
2922
3012
  stop = StopFlag()
2923
3013
  _install_sigint(stop)
3014
+ _install_run_stop_signal(stop)
3015
+ _install_sigterm(stop)
2924
3016
  history: list[dict] = []
2925
3017
  transcript = ""
2926
3018
 
@@ -2963,7 +3055,7 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
2963
3055
  failure = _run_kickoff_command(
2964
3056
  cfg, run_dir=run_dir, transcript_path=transcript_path,
2965
3057
  recap_path=recap_path, state_path=state_path, history=history,
2966
- wt_path=None, wt_branch=None, log=log,
3058
+ wt_path=None, wt_branch=None, stop=stop, log=log,
2967
3059
  )
2968
3060
  if failure is not None:
2969
3061
  return context, failure
@@ -2978,6 +3070,16 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
2978
3070
  cfg, run_id, run_dir
2979
3071
  )
2980
3072
  except RunSetupError as exc:
3073
+ if stop.requested and stop.reason == "SIGTERM":
3074
+ log("duet", "stop", "[duet] immediate stop requested", kind="stop")
3075
+ state = _requested_stop_state(
3076
+ cfg,
3077
+ history=history,
3078
+ transcript_path=transcript_path,
3079
+ recap_path=recap_path,
3080
+ state_path=state_path,
3081
+ )
3082
+ return context, state
2981
3083
  state = _launch_failure_state(
2982
3084
  cfg, reason=FINISHED_SETUP_ERROR, message=str(exc), history=history,
2983
3085
  transcript_path=transcript_path, recap_path=recap_path,
@@ -3026,7 +3128,7 @@ def run_duet(cfg: DuetConfig) -> dict:
3026
3128
  seed, seed_failure_state = _derive_seed_or_failure(
3027
3129
  cfg, run_dir=run_dir, transcript_path=transcript_path,
3028
3130
  recap_path=recap_path, state_path=state_path, history=history,
3029
- wt_path=wt_path, wt_branch=wt_branch, log=log,
3131
+ wt_path=wt_path, wt_branch=wt_branch, stop=stop, log=log,
3030
3132
  )
3031
3133
  if seed_failure_state is not None:
3032
3134
  return seed_failure_state
@@ -3063,6 +3165,9 @@ def run_duet(cfg: DuetConfig) -> dict:
3063
3165
  else:
3064
3166
  print(result.reply)
3065
3167
 
3168
+ if stop.requested and stop.reason == "SIGTERM":
3169
+ finished_reason = FINISHED_FORCE_STOP
3170
+ break
3066
3171
  if result.failure_reason is not None:
3067
3172
  if _continue_after_timeout(result, may_continue, stop):
3068
3173
  print(f"[duet] turn {turn:02d} ({speaker.name}) timed out after "
@@ -3076,14 +3181,13 @@ def run_duet(cfg: DuetConfig) -> dict:
3076
3181
  continue
3077
3182
  finished_reason = result.failure_reason
3078
3183
  break
3184
+ if stop.requested:
3185
+ finished_reason = FINISHED_FORCE_STOP
3186
+ break
3079
3187
  previous_turn_timed_out = False
3080
3188
  if result.convergence_hit and previous_convergence_proposal:
3081
3189
  finished_reason = FINISHED_CONVERGED
3082
3190
  break
3083
- if stop.requested:
3084
- finished_reason = FINISHED_FORCE_STOP
3085
- break
3086
-
3087
3191
  last_msg = result.reply
3088
3192
  previous_convergence_proposal = result.convergence_hit
3089
3193
  speaker_idx = 1 - speaker_idx
@@ -3095,7 +3199,7 @@ def run_duet(cfg: DuetConfig) -> dict:
3095
3199
  finished_reason, forced_verify_state = ask_force(
3096
3200
  cfg, history, transcript_path, state_path,
3097
3201
  last_msg, speaker_idx, seen_first_turn,
3098
- finished_reason, wt_path, wt_branch
3202
+ finished_reason, wt_path, wt_branch, stop=stop,
3099
3203
  )
3100
3204
  if forced_verify_state is not None:
3101
3205
  last_verify_state = forced_verify_state
@@ -3227,8 +3331,11 @@ def ask_force(cfg: DuetConfig, history: list, transcript_path: pathlib.Path,
3227
3331
  state_path: pathlib.Path, last_msg: str, speaker_idx: int,
3228
3332
  seen_first_turn: dict, reason: str,
3229
3333
  wt_path: Optional[pathlib.Path] = None,
3230
- wt_branch: Optional[str] = None) -> tuple[str, Optional[dict]]:
3334
+ wt_branch: Optional[str] = None,
3335
+ stop: Optional[StopFlag] = None) -> tuple[str, Optional[dict]]:
3231
3336
  """Post-loop interactive prompt: human can push another turn or accept."""
3337
+ if stop is not None and stop.requested:
3338
+ return FINISHED_FORCE_STOP, None
3232
3339
  if not sys.stdin.isatty():
3233
3340
  return reason, None
3234
3341
  last_verify_state: Optional[dict] = None
@@ -3253,7 +3360,12 @@ def ask_force(cfg: DuetConfig, history: list, transcript_path: pathlib.Path,
3253
3360
  try:
3254
3361
  line = input("force> ").strip()
3255
3362
  except EOFError:
3256
- return reason, last_verify_state
3363
+ return (
3364
+ FINISHED_FORCE_STOP if stop is not None and stop.requested else reason,
3365
+ last_verify_state,
3366
+ )
3367
+ if stop is not None and stop.requested:
3368
+ return FINISHED_FORCE_STOP, last_verify_state
3257
3369
  if not line:
3258
3370
  return reason, last_verify_state
3259
3371
  next_speaker = cfg.agents[speaker_idx]
@@ -3274,21 +3386,24 @@ def ask_force(cfg: DuetConfig, history: list, transcript_path: pathlib.Path,
3274
3386
  last_verify_state=last_verify_state,
3275
3387
  )
3276
3388
  last_verify_state = result.last_verify_state
3389
+ forced_reason = result.failure_reason
3390
+ if stop is not None and stop.requested:
3391
+ forced_reason = FINISHED_FORCE_STOP
3277
3392
  # Persist each forced turn so a crash at the next force> prompt doesn't
3278
3393
  # lose it (the --status/--continue durability contract). finished_reason
3279
3394
  # stays None mid-loop (duet is alive at the prompt); run_duet writes the
3280
3395
  # final state with the real reason once ask_force returns.
3281
3396
  forced_state = _build_run_state(
3282
3397
  cfg, turns_used=len(history), history=history,
3283
- finished_reason=result.failure_reason,
3398
+ finished_reason=forced_reason,
3284
3399
  transcript_path=transcript_path,
3285
3400
  recap_path=transcript_path.parent / "recap.md",
3286
3401
  last_verify=last_verify_state, wt_path=wt_path, wt_branch=wt_branch,
3287
3402
  phase="awaiting_force",
3288
3403
  )
3289
3404
  _write_run_state(state_path, forced_state)
3290
- if result.failure_reason is not None:
3291
- return result.failure_reason, last_verify_state
3405
+ if forced_reason is not None:
3406
+ return forced_reason, last_verify_state
3292
3407
  last_msg = result.reply
3293
3408
  speaker_idx = 1 - speaker_idx
3294
3409
  reason = FINISHED_FORCED_CONTINUATION
@@ -3797,16 +3912,8 @@ def _proc_cmdline(pid: int) -> Optional[str]:
3797
3912
  return None
3798
3913
 
3799
3914
 
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
3915
+ def _looks_like_duet_cmdline(cmdline: str) -> bool:
3916
+ """Return whether a command line identifies a duet entry point."""
3810
3917
  try:
3811
3918
  argv = shlex.split(cmdline)
3812
3919
  except ValueError:
@@ -3824,6 +3931,90 @@ def _is_duet_process(pid: int) -> bool:
3824
3931
  return False
3825
3932
 
3826
3933
 
3934
+ def _proc_start_identity(pid: int) -> Optional[str]:
3935
+ """Return the strongest stable process-start identity available."""
3936
+ if sys.platform.startswith("linux"):
3937
+ try:
3938
+ stat = pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
3939
+ # The command name is parenthesized and can contain spaces. Fields
3940
+ # after its final ')' start at procfs field 3; starttime is field 22.
3941
+ fields = stat[stat.rfind(")") + 2:].split()
3942
+ return f"linux-proc-start:{fields[19]}"
3943
+ except (OSError, IndexError):
3944
+ return None
3945
+ if sys.platform == "darwin":
3946
+ try:
3947
+ import ctypes
3948
+
3949
+ class ProcBsdInfo(ctypes.Structure):
3950
+ _fields_ = [
3951
+ ("flags", ctypes.c_uint32),
3952
+ ("status", ctypes.c_uint32),
3953
+ ("xstatus", ctypes.c_uint32),
3954
+ ("pid", ctypes.c_uint32),
3955
+ ("ppid", ctypes.c_uint32),
3956
+ ("uid", ctypes.c_uint32),
3957
+ ("gid", ctypes.c_uint32),
3958
+ ("ruid", ctypes.c_uint32),
3959
+ ("rgid", ctypes.c_uint32),
3960
+ ("svuid", ctypes.c_uint32),
3961
+ ("svgid", ctypes.c_uint32),
3962
+ ("rfu", ctypes.c_uint32),
3963
+ ("comm", ctypes.c_char * 16),
3964
+ ("name", ctypes.c_char * 32),
3965
+ ("nfiles", ctypes.c_uint32),
3966
+ ("pgid", ctypes.c_uint32),
3967
+ ("pjobc", ctypes.c_uint32),
3968
+ ("e_tdev", ctypes.c_uint32),
3969
+ ("e_tpgid", ctypes.c_uint32),
3970
+ ("nice", ctypes.c_int32),
3971
+ ("start_sec", ctypes.c_uint64),
3972
+ ("start_usec", ctypes.c_uint64),
3973
+ ]
3974
+
3975
+ info = ProcBsdInfo()
3976
+ libproc = ctypes.CDLL("/usr/lib/libproc.dylib")
3977
+ size = ctypes.sizeof(info)
3978
+ read = libproc.proc_pidinfo(
3979
+ pid, 3, 0, ctypes.byref(info), size # PROC_PIDTBSDINFO
3980
+ )
3981
+ if read == size and info.pid == pid and info.start_sec:
3982
+ return f"darwin-proc-start:{info.start_sec}:{info.start_usec}"
3983
+ except (AttributeError, OSError, TypeError, ValueError):
3984
+ pass
3985
+ try:
3986
+ result = subprocess.run(
3987
+ ["ps", "-o", "lstart=", "-p", str(pid)],
3988
+ capture_output=True,
3989
+ text=True,
3990
+ encoding="utf-8",
3991
+ errors="replace",
3992
+ timeout=2,
3993
+ )
3994
+ started = " ".join(result.stdout.split())
3995
+ if result.returncode == 0 and started:
3996
+ return f"ps-lstart:{started}"
3997
+ except (subprocess.TimeoutExpired, OSError):
3998
+ pass
3999
+ return None
4000
+
4001
+
4002
+ _CURRENT_PROCESS_START_IDENTITY = _proc_start_identity(os.getpid())
4003
+
4004
+
4005
+ def _is_duet_process(pid: int) -> bool:
4006
+ """True if `pid` is alive AND looks like a duet.py process."""
4007
+ if not _pid_alive(pid):
4008
+ return False
4009
+ cmdline = _proc_cmdline(pid)
4010
+ if cmdline is None:
4011
+ # Status is observational. A sandbox can hide identity inspection, so
4012
+ # retain its prior liveness fallback. Mutating stop operations use the
4013
+ # stricter proof in `_validated_stop_target` instead.
4014
+ return pid != 1
4015
+ return _looks_like_duet_cmdline(cmdline)
4016
+
4017
+
3827
4018
  def _utc_rfc3339(value: float) -> str:
3828
4019
  timestamp = dt.datetime.fromtimestamp(value, tz=dt.timezone.utc)
3829
4020
  return timestamp.isoformat(timespec="seconds").replace("+00:00", "Z")
@@ -4243,6 +4434,141 @@ def _resolve_run_dir(arg: str, *, warn_ambiguous: bool = True) -> Optional[pathl
4243
4434
  return None
4244
4435
 
4245
4436
 
4437
+ def _bare_stop_run_candidates(arg: str) -> list[pathlib.Path]:
4438
+ """Return distinct directories addressed by one bare run id."""
4439
+ if "/" in arg or "\\" in arg or not _RUN_ID_RE.match(arg):
4440
+ return []
4441
+ candidates = [pathlib.Path(arg), *(
4442
+ root / arg for root in _default_list_paths()
4443
+ )]
4444
+ unique: list[pathlib.Path] = []
4445
+ seen: set[pathlib.Path] = set()
4446
+ for candidate in candidates:
4447
+ if not candidate.is_dir():
4448
+ continue
4449
+ try:
4450
+ resolved = candidate.resolve()
4451
+ except OSError:
4452
+ continue
4453
+ if resolved in seen:
4454
+ continue
4455
+ seen.add(resolved)
4456
+ unique.append(resolved)
4457
+ return unique
4458
+
4459
+
4460
+ class StopTargetError(RuntimeError):
4461
+ """A run cannot be safely resolved or proven before signaling."""
4462
+
4463
+ def __init__(self, message: str, exit_code: int) -> None:
4464
+ super().__init__(message)
4465
+ self.exit_code = exit_code
4466
+
4467
+
4468
+ def _read_stop_state(run_dir: pathlib.Path) -> dict:
4469
+ state_path = run_dir / "state.json"
4470
+ if not state_path.is_file():
4471
+ raise StopTargetError(f"missing state.json in {run_dir}", 3)
4472
+ try:
4473
+ state = json.loads(state_path.read_text(encoding="utf-8"))
4474
+ except (json.JSONDecodeError, OSError) as exc:
4475
+ raise StopTargetError(
4476
+ f"cannot read state.json in {run_dir}: {type(exc).__name__}", 3
4477
+ ) from exc
4478
+ if not isinstance(state, dict):
4479
+ raise StopTargetError(f"state.json root is not an object in {run_dir}", 3)
4480
+ return state
4481
+
4482
+
4483
+ def _validated_stop_target(arg: str) -> tuple[pathlib.Path, int]:
4484
+ """Resolve a run and prove its exact live supervisor identity.
4485
+
4486
+ Unlike status, this mutating path never falls back from hidden process
4487
+ identity to liveness alone. It also binds the PID to the process-start
4488
+ identity saved by that supervisor, which detects PID reuse.
4489
+ """
4490
+ bare_candidates = _bare_stop_run_candidates(arg)
4491
+ if len(bare_candidates) > 1:
4492
+ raise StopTargetError(
4493
+ f"run id {arg!r} is ambiguous across multiple roots; "
4494
+ "pass an explicit run directory path",
4495
+ 2,
4496
+ )
4497
+ run_dir = bare_candidates[0] if bare_candidates else _resolve_run_dir(arg)
4498
+ if run_dir is None:
4499
+ raise StopTargetError(f"run directory not found: {arg}", 3)
4500
+ state = _read_stop_state(run_dir)
4501
+ if state.get("finished_reason") is not None or state.get("phase") == "finished":
4502
+ raise StopTargetError(f"run is already terminal: {run_dir}", 2)
4503
+
4504
+ pid = _coerce_pid(state.get("duet_pid"))
4505
+ if pid is None:
4506
+ raise StopTargetError(f"run has an invalid duet_pid: {run_dir}", 2)
4507
+ if pid == 1:
4508
+ raise StopTargetError("refusing to signal PID 1", 2)
4509
+ if not _pid_alive(pid):
4510
+ raise StopTargetError(f"duet supervisor PID {pid} is stale", 2)
4511
+
4512
+ saved_start = state.get("duet_process_start")
4513
+ if not isinstance(saved_start, str) or not saved_start:
4514
+ raise StopTargetError(
4515
+ "run lacks a saved supervisor start identity; refusing unsafe stop", 2
4516
+ )
4517
+ live_start = _proc_start_identity(pid)
4518
+ if live_start is None:
4519
+ raise StopTargetError(
4520
+ f"cannot prove the start identity of duet supervisor PID {pid}", 2
4521
+ )
4522
+ if live_start != saved_start:
4523
+ raise StopTargetError(
4524
+ f"duet supervisor PID {pid} was reused; refusing to signal it", 2
4525
+ )
4526
+ cmdline = _proc_cmdline(pid)
4527
+ if cmdline is not None and not _looks_like_duet_cmdline(cmdline):
4528
+ raise StopTargetError(
4529
+ f"PID {pid} belongs to a foreign process; refusing to signal it", 2
4530
+ )
4531
+ return run_dir, pid
4532
+
4533
+
4534
+ def request_run_stop(arg: str, *, immediate: bool = False) -> int:
4535
+ """Signal one validated supervisor. Return 0=sent, 2=unsafe, 3=error."""
4536
+ try:
4537
+ run_dir, pid = _validated_stop_target(arg)
4538
+ # Re-read all identity evidence immediately before the signal. This
4539
+ # narrows the unavoidable check/signal race and catches state changes.
4540
+ checked_dir, checked_pid = _validated_stop_target(str(run_dir))
4541
+ if checked_dir != run_dir or checked_pid != pid:
4542
+ raise StopTargetError("run supervisor identity changed during stop", 2)
4543
+ if not immediate and STOP_GRACEFUL_SIGNAL is None:
4544
+ raise StopTargetError(
4545
+ "this platform has no run-scoped graceful stop signal", 2
4546
+ )
4547
+ stop_signal = (
4548
+ STOP_IMMEDIATE_SIGNAL if immediate else STOP_GRACEFUL_SIGNAL
4549
+ )
4550
+ os.kill(pid, stop_signal)
4551
+ except StopTargetError as exc:
4552
+ print(f"[duet] stop refused: {exc}", file=sys.stderr)
4553
+ return exc.exit_code
4554
+ except ProcessLookupError:
4555
+ print(
4556
+ f"[duet] stop refused: duet supervisor PID {pid} became stale",
4557
+ file=sys.stderr,
4558
+ )
4559
+ return 2
4560
+ except PermissionError:
4561
+ print(
4562
+ f"[duet] stop refused: permission denied for duet supervisor PID {pid}",
4563
+ file=sys.stderr,
4564
+ )
4565
+ return 2
4566
+
4567
+ mode = "immediate" if immediate else "graceful"
4568
+ print(f"[duet] {mode} stop requested for {run_dir} (supervisor PID {pid})")
4569
+ return 0
4570
+
4571
+
4246
4572
  def _load_run_state(run_dir: pathlib.Path,
4247
4573
  parser: argparse.ArgumentParser,
4248
4574
  option_name: str) -> dict:
@@ -4627,6 +4953,26 @@ def print_runs_list(explicit_path: Optional[pathlib.Path]) -> int:
4627
4953
  return 0
4628
4954
 
4629
4955
 
4956
+ def _add_control_arguments(parser: argparse.ArgumentParser) -> None:
4957
+ parser.add_argument(
4958
+ "--continue", metavar="RUN_DIR_OR_ID", dest="continue_run",
4959
+ help="start a new run from an existing run's state.json: restore "
4960
+ "agents/session ids, reuse its worktree when available, and send "
4961
+ "the next agent a continuation kickoff. Seed flags can add guidance.",
4962
+ )
4963
+ parser.add_argument(
4964
+ "--stop", metavar="RUN_DIR_OR_ID", default=None,
4965
+ help="request a graceful stop for one live run. Resolves like --status, "
4966
+ "proves the exact supervisor PID and process-start identity, then "
4967
+ "signals only that PID. The current child turn can finish.",
4968
+ )
4969
+ parser.add_argument(
4970
+ "--immediate", action="store_true",
4971
+ help="with --stop, terminate that run's active child process group and "
4972
+ "finish the run with reason=force_stop",
4973
+ )
4974
+
4975
+
4630
4976
  def _build_arg_parser() -> argparse.ArgumentParser:
4631
4977
  ap = argparse.ArgumentParser(
4632
4978
  description="duet — two CLI agents in conversation, with per-agent session memory.")
@@ -4639,11 +4985,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
4639
4985
  "its latest message and feed it to the partner agent.")
4640
4986
  ap.add_argument("--resume-codex", metavar="SESSION_ID",
4641
4987
  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.")
4988
+ _add_control_arguments(ap)
4647
4989
  ap.add_argument("--task", help="task description, @file, or @- stdin "
4648
4990
  "(used if no --resume-* and no --kickoff)")
4649
4991
  ap.add_argument("--kickoff", help="explicit first message, @file, or @- stdin "
@@ -4730,8 +5072,8 @@ def _build_arg_parser() -> argparse.ArgumentParser:
4730
5072
  "`add_dirs:` (list).")
4731
5073
  ap.add_argument("--reasoning", choices=REASONING_LEVELS, default=None,
4732
5074
  help="reasoning effort for both agents. Codex: passes "
4733
- "`-c model_reasoning_effort=<v>` except for medium "
4734
- "(max → xhigh). Claude: passes `--effort <v>` "
5075
+ "`-c model_reasoning_effort=<v>` unchanged. Claude: "
5076
+ "passes `--effort <v>` "
4735
5077
  "(minimal → low) and adds high/xhigh/max prompt nudges. "
4736
5078
  "Copilot: passes `--effort <v>` (minimal → none). "
4737
5079
  "Gemini: no effort flag; high/xhigh/max are prompt nudges only.")
@@ -5154,6 +5496,15 @@ def main() -> int:
5154
5496
  ap = _build_arg_parser()
5155
5497
  args = ap.parse_args()
5156
5498
 
5499
+ if args.immediate and args.stop is None:
5500
+ ap.error("--immediate is only valid with --stop")
5501
+ if args.stop is not None:
5502
+ if args.status or args.list_runs is not None or args.continue_run:
5503
+ ap.error("--stop cannot be combined with --status, --list, or --continue")
5504
+ if args.json_output:
5505
+ ap.error("--json is only valid with --status")
5506
+ return request_run_stop(args.stop, immediate=args.immediate)
5507
+
5157
5508
  # `--status` is read-only: print run health and exit. Skip everything below.
5158
5509
  if args.status:
5159
5510
  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.12
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()
@@ -1045,10 +1045,13 @@ class TestReasoningHelpers(unittest.TestCase):
1045
1045
  self.assertIn(level, duet.COPILOT_REASONING_PROMPT_PREFIX)
1046
1046
  self.assertIn(level, duet.OPENCODE_REASONING_PROMPT_PREFIX)
1047
1047
 
1048
- def test_codex_max_maps_to_xhigh(self) -> None:
1049
- # Codex documents `xhigh` but not `max`; duet keeps `max` as a
1050
- # backend-normalized alias for the highest Codex effort.
1051
- self.assertEqual(duet.CODEX_REASONING_MAP["max"], "xhigh")
1048
+ def test_codex_reasoning_levels_pass_through_exactly(self) -> None:
1049
+ # Model support varies. Duet must not silently change the requested
1050
+ # value before Codex validates it for the selected model.
1051
+ self.assertEqual(
1052
+ duet.CODEX_REASONING_MAP,
1053
+ {level: level for level in duet.REASONING_LEVELS},
1054
+ )
1052
1055
 
1053
1056
 
1054
1057
  class TestAgentExitError(unittest.TestCase):
@@ -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