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.
- {duet_cli-0.2.10/duet_cli.egg-info → duet_cli-0.2.12}/PKG-INFO +13 -3
- {duet_cli-0.2.10 → duet_cli-0.2.12}/README.md +12 -2
- {duet_cli-0.2.10 → duet_cli-0.2.12}/duet.py +390 -39
- {duet_cli-0.2.10 → duet_cli-0.2.12/duet_cli.egg-info}/PKG-INFO +13 -3
- {duet_cli-0.2.10 → duet_cli-0.2.12}/duet_cli.egg-info/SOURCES.txt +2 -1
- {duet_cli-0.2.10 → duet_cli-0.2.12}/tests/test_control_plane.py +197 -1
- {duet_cli-0.2.10 → duet_cli-0.2.12}/tests/test_duet.py +7 -4
- duet_cli-0.2.12/tests/test_stop_control_plane.py +200 -0
- {duet_cli-0.2.10 → duet_cli-0.2.12}/LICENSE +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.12}/duet_cli.egg-info/dependency_links.txt +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.12}/duet_cli.egg-info/entry_points.txt +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.12}/duet_cli.egg-info/requires.txt +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.12}/duet_cli.egg-info/top_level.txt +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.12}/pyproject.toml +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.12}/setup.cfg +0 -0
- {duet_cli-0.2.10 → duet_cli-0.2.12}/tests/test_bump_release_version.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.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`,
|
|
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.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
|
-
#
|
|
276
|
-
#
|
|
277
|
-
#
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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=
|
|
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
|
|
3291
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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>`
|
|
4734
|
-
"
|
|
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.
|
|
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`,
|
|
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()
|
|
@@ -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
|
|
1049
|
-
#
|
|
1050
|
-
#
|
|
1051
|
-
self.assertEqual(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|