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.
- {duet_cli-0.2.10/duet_cli.egg-info → duet_cli-0.2.11}/PKG-INFO +13 -3
- {duet_cli-0.2.10 → duet_cli-0.2.11}/README.md +12 -2
- {duet_cli-0.2.10 → duet_cli-0.2.11}/duet.py +382 -29
- {duet_cli-0.2.10 → duet_cli-0.2.11/duet_cli.egg-info}/PKG-INFO +13 -3
- {duet_cli-0.2.10 → duet_cli-0.2.11}/duet_cli.egg-info/SOURCES.txt +2 -1
- {duet_cli-0.2.10 → duet_cli-0.2.11}/tests/test_control_plane.py +197 -1
- duet_cli-0.2.11/tests/test_stop_control_plane.py +200 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/LICENSE +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/duet_cli.egg-info/dependency_links.txt +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/duet_cli.egg-info/entry_points.txt +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/duet_cli.egg-info/requires.txt +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/duet_cli.egg-info/top_level.txt +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/pyproject.toml +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/setup.cfg +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/tests/test_bump_release_version.py +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.11}/tests/test_duet.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duet-cli
|
|
3
|
-
Version: 0.2.
|
|
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`,
|
|
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`,
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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=
|
|
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
|
|
3291
|
-
return
|
|
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
|
|
3801
|
-
"""
|
|
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
|
|
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.
|
|
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`,
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|