duet-cli 0.2.6__tar.gz → 0.2.7__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.6/duet_cli.egg-info → duet_cli-0.2.7}/PKG-INFO +3 -1
- {duet_cli-0.2.6 → duet_cli-0.2.7}/README.md +2 -0
- {duet_cli-0.2.6 → duet_cli-0.2.7}/duet.py +419 -99
- {duet_cli-0.2.6 → duet_cli-0.2.7/duet_cli.egg-info}/PKG-INFO +3 -1
- {duet_cli-0.2.6 → duet_cli-0.2.7}/pyproject.toml +1 -1
- {duet_cli-0.2.6 → duet_cli-0.2.7}/tests/test_duet.py +193 -4
- {duet_cli-0.2.6 → duet_cli-0.2.7}/LICENSE +0 -0
- {duet_cli-0.2.6 → duet_cli-0.2.7}/duet_cli.egg-info/SOURCES.txt +0 -0
- {duet_cli-0.2.6 → duet_cli-0.2.7}/duet_cli.egg-info/dependency_links.txt +0 -0
- {duet_cli-0.2.6 → duet_cli-0.2.7}/duet_cli.egg-info/entry_points.txt +0 -0
- {duet_cli-0.2.6 → duet_cli-0.2.7}/duet_cli.egg-info/requires.txt +0 -0
- {duet_cli-0.2.6 → duet_cli-0.2.7}/duet_cli.egg-info/top_level.txt +0 -0
- {duet_cli-0.2.6 → duet_cli-0.2.7}/setup.cfg +0 -0
- {duet_cli-0.2.6 → duet_cli-0.2.7}/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.7
|
|
4
4
|
Summary: Two CLI agents in conversation. One Python file. Stdlib only.
|
|
5
5
|
Author: Volkan Altan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -166,6 +166,8 @@ duet --reasoning high --codex-fast \
|
|
|
166
166
|
--task "Fix the issue" --cwd ~/workspace/project
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
Use `--no-codex-fast` to override a config or continued run that enabled it.
|
|
170
|
+
|
|
169
171
|
**Verify gate** — a convergence proposal only counts if `make test` exits 0;
|
|
170
172
|
any failure feeds back into the next turn:
|
|
171
173
|
|
|
@@ -145,6 +145,8 @@ duet --reasoning high --codex-fast \
|
|
|
145
145
|
--task "Fix the issue" --cwd ~/workspace/project
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
Use `--no-codex-fast` to override a config or continued run that enabled it.
|
|
149
|
+
|
|
148
150
|
**Verify gate** — a convergence proposal only counts if `make test` exits 0;
|
|
149
151
|
any failure feeds back into the next turn:
|
|
150
152
|
|
|
@@ -58,16 +58,22 @@ import sys
|
|
|
58
58
|
import textwrap
|
|
59
59
|
import threading
|
|
60
60
|
import time
|
|
61
|
+
import traceback
|
|
61
62
|
from typing import Callable, Optional
|
|
62
63
|
|
|
63
64
|
# ---------- defaults ----------
|
|
64
65
|
|
|
65
66
|
DEFAULT_SENTINEL = "<<<LGTM>>>"
|
|
67
|
+
DEFAULT_SANDBOX = "workspace-write"
|
|
68
|
+
DEFAULT_PERMISSION_MODE = "acceptEdits"
|
|
69
|
+
SAFE_CONTINUE_SANDBOXES = {"read-only", DEFAULT_SANDBOX}
|
|
70
|
+
SAFE_CONTINUE_PERMISSION_MODES = {"default", DEFAULT_PERMISSION_MODE, "plan"}
|
|
66
71
|
DEFAULT_TURNS = 2
|
|
67
72
|
DEFAULT_TIMEOUT = 60 * 15
|
|
68
73
|
TASK_MAX_CHARS = 512 * 1024
|
|
69
74
|
CONVERGENCE_RATIONALE_MIN_CHARS = 20
|
|
70
75
|
VERIFY_OUTPUT_TAIL_CHARS = 4000
|
|
76
|
+
AGENT_ERROR_TRANSCRIPT_MAX_CHARS = 12_000
|
|
71
77
|
VERIFY_LIVE_PREFIX = " │ [verify] "
|
|
72
78
|
WORKTREE_FOR_CHOICES = {"lead", "partner"}
|
|
73
79
|
FINISHED_CONVERGED = "converged"
|
|
@@ -317,8 +323,8 @@ class DuetConfig:
|
|
|
317
323
|
sentinel: str = DEFAULT_SENTINEL
|
|
318
324
|
per_turn_timeout: int = DEFAULT_TIMEOUT
|
|
319
325
|
runs_dir: pathlib.Path = pathlib.Path("runs")
|
|
320
|
-
sandbox: str =
|
|
321
|
-
permission_mode: str =
|
|
326
|
+
sandbox: str = DEFAULT_SANDBOX # codex
|
|
327
|
+
permission_mode: str = DEFAULT_PERMISSION_MODE # claude/gemini
|
|
322
328
|
dry_run: bool = False
|
|
323
329
|
recap: bool = False
|
|
324
330
|
verify_cmd: Optional[str] = None # shell command that must pass before
|
|
@@ -514,7 +520,8 @@ def is_git_repo(path: pathlib.Path) -> bool:
|
|
|
514
520
|
try:
|
|
515
521
|
r = subprocess.run(
|
|
516
522
|
["git", "-C", str(path), "rev-parse", "--is-inside-work-tree"],
|
|
517
|
-
capture_output=True, text=True,
|
|
523
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
524
|
+
timeout=5,
|
|
518
525
|
)
|
|
519
526
|
return r.returncode == 0 and r.stdout.strip() == "true"
|
|
520
527
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
@@ -540,6 +547,8 @@ def setup_worktree(repo_path: pathlib.Path, branch_name: str,
|
|
|
540
547
|
stdout=subprocess.PIPE,
|
|
541
548
|
stderr=subprocess.PIPE,
|
|
542
549
|
text=True,
|
|
550
|
+
encoding="utf-8",
|
|
551
|
+
errors="replace",
|
|
543
552
|
start_new_session=True,
|
|
544
553
|
)
|
|
545
554
|
except FileNotFoundError:
|
|
@@ -568,15 +577,18 @@ def git_diff_summary(wt_path: pathlib.Path, max_chars: int = 8000) -> str:
|
|
|
568
577
|
try:
|
|
569
578
|
status = subprocess.run(
|
|
570
579
|
["git", "-C", str(wt_path), "status", "--short"],
|
|
571
|
-
capture_output=True, text=True,
|
|
580
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
581
|
+
timeout=10,
|
|
572
582
|
).stdout.rstrip()
|
|
573
583
|
diff = subprocess.run(
|
|
574
584
|
["git", "-C", str(wt_path), "diff", "HEAD", "--stat"],
|
|
575
|
-
capture_output=True, text=True,
|
|
585
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
586
|
+
timeout=10,
|
|
576
587
|
).stdout.rstrip()
|
|
577
588
|
full = subprocess.run(
|
|
578
589
|
["git", "-C", str(wt_path), "diff", "HEAD"],
|
|
579
|
-
capture_output=True, text=True,
|
|
590
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
591
|
+
timeout=10,
|
|
580
592
|
).stdout
|
|
581
593
|
if len(full) > max_chars:
|
|
582
594
|
full = full[:max_chars] + f"\n…[truncated, {len(full)-max_chars} more chars]"
|
|
@@ -837,7 +849,8 @@ def _run(cmd: list[str], *, cwd: pathlib.Path, stdin: Optional[str], timeout: in
|
|
|
837
849
|
proc = subprocess.Popen(
|
|
838
850
|
cmd, cwd=str(cwd),
|
|
839
851
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
840
|
-
text=True,
|
|
852
|
+
text=True, encoding="utf-8", errors="replace",
|
|
853
|
+
bufsize=1, # line-buffered
|
|
841
854
|
start_new_session=True,
|
|
842
855
|
)
|
|
843
856
|
except FileNotFoundError:
|
|
@@ -848,7 +861,7 @@ def _run(cmd: list[str], *, cwd: pathlib.Path, stdin: Optional[str], timeout: in
|
|
|
848
861
|
pid_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
849
862
|
# Write atomically so a poller never reads a half-written PID.
|
|
850
863
|
tmp = pid_file_path.with_name(pid_file_path.name + ".tmp")
|
|
851
|
-
tmp.write_text(f"{proc.pid}\n")
|
|
864
|
+
tmp.write_text(f"{proc.pid}\n", encoding="utf-8")
|
|
852
865
|
os.replace(tmp, pid_file_path)
|
|
853
866
|
except OSError as e:
|
|
854
867
|
print(f"[duet] warn: pid file write failed ({pid_file_path}): {e}",
|
|
@@ -1158,31 +1171,46 @@ def call_claude(agent: Agent, system_prompt: str, message: str,
|
|
|
1158
1171
|
# Codex's `codex exec` prints a line like `session id: 019e12ad-0b1b-7732-bd7b-6acbbd04ab46`
|
|
1159
1172
|
# to stderr near startup; modern builds also re-emit it on resume. We pin to that
|
|
1160
1173
|
# UUID for subsequent resumes so duet doesn't depend on `--last`'s cwd-keyed
|
|
1161
|
-
# lookup.
|
|
1162
|
-
#
|
|
1174
|
+
# lookup. Match only line-start session labels so a stray inline UUID or tool
|
|
1175
|
+
# trace does not become a resume pin; case-insensitive because the label has
|
|
1176
|
+
# varied.
|
|
1163
1177
|
_CODEX_UUID_PATTERN = (
|
|
1164
1178
|
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
1165
1179
|
)
|
|
1166
1180
|
_CODEX_SESSION_ID_RE = re.compile(
|
|
1167
|
-
r"session[ _-]?id\s*[:=]\s*(" + _CODEX_UUID_PATTERN + r")",
|
|
1168
|
-
re.IGNORECASE,
|
|
1181
|
+
r"^\s*session[ _-]?id\s*[:=]\s*(" + _CODEX_UUID_PATTERN + r")",
|
|
1182
|
+
re.IGNORECASE | re.MULTILINE,
|
|
1169
1183
|
)
|
|
1170
1184
|
_CODEX_UUID_RE = re.compile(r"\A" + _CODEX_UUID_PATTERN + r"\Z", re.IGNORECASE)
|
|
1171
1185
|
|
|
1172
1186
|
|
|
1173
1187
|
def _parse_codex_session_id(stderr: str) -> Optional[str]:
|
|
1174
|
-
"""Return the
|
|
1188
|
+
"""Return the first line-start `session id: <uuid>` from Codex stderr.
|
|
1175
1189
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
must not be confused for the harness's session pin.
|
|
1190
|
+
Prefer the startup line over later stderr content from tools. Returns the
|
|
1191
|
+
UUID lowercased; None if no match. We never parse stdout — Codex puts the
|
|
1192
|
+
assistant reply there and a UUID inside the reply must not be confused for
|
|
1193
|
+
the harness's session pin.
|
|
1181
1194
|
"""
|
|
1182
1195
|
if not stderr:
|
|
1183
1196
|
return None
|
|
1184
|
-
|
|
1185
|
-
return
|
|
1197
|
+
match = _CODEX_SESSION_ID_RE.search(stderr)
|
|
1198
|
+
return match.group(1).lower() if match else None
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _resolve_codex_session_pin(agent: Agent, parsed_sid: Optional[str]) -> Optional[str]:
|
|
1202
|
+
"""Merge a parsed Codex session id with the agent's existing resume pin."""
|
|
1203
|
+
if not parsed_sid:
|
|
1204
|
+
return agent.session_id
|
|
1205
|
+
if agent.session_id and _CODEX_UUID_RE.match(agent.session_id):
|
|
1206
|
+
if parsed_sid != agent.session_id.lower():
|
|
1207
|
+
print(
|
|
1208
|
+
"[duet] WARNING: codex stderr reported a different session id "
|
|
1209
|
+
f"({parsed_sid}); keeping established pin {agent.session_id}",
|
|
1210
|
+
file=sys.stderr,
|
|
1211
|
+
)
|
|
1212
|
+
return agent.session_id
|
|
1213
|
+
return parsed_sid
|
|
1186
1214
|
|
|
1187
1215
|
|
|
1188
1216
|
def call_codex(agent: Agent, system_prompt: str, message: str,
|
|
@@ -1235,10 +1263,10 @@ def call_codex(agent: Agent, system_prompt: str, message: str,
|
|
|
1235
1263
|
# `["--full-auto"]` or `["--yolo"]`) and config overrides (`-c …`).
|
|
1236
1264
|
#
|
|
1237
1265
|
# IMPORTANT: `codex exec resume` accepts a SUBSET of `codex exec`'s
|
|
1238
|
-
# flags. In particular, `--sandbox` and `--cd` are exec-only
|
|
1239
|
-
#
|
|
1240
|
-
#
|
|
1241
|
-
#
|
|
1266
|
+
# flags. In particular, `--sandbox` and `--cd` are exec-only, and codex's
|
|
1267
|
+
# clap parser rejects them on resume with "unexpected argument '--sandbox'
|
|
1268
|
+
# found". Resume does accept config overrides, so reassert the sandbox via
|
|
1269
|
+
# `sandbox_mode` instead of relying on the resumed invocation's defaults.
|
|
1242
1270
|
shared_opts = ["--skip-git-repo-check"]
|
|
1243
1271
|
if agent.model:
|
|
1244
1272
|
shared_opts += ["--model", agent.model]
|
|
@@ -1250,9 +1278,16 @@ def call_codex(agent: Agent, system_prompt: str, message: str,
|
|
|
1250
1278
|
cmd = ["codex", "exec", *options, full_prompt]
|
|
1251
1279
|
else:
|
|
1252
1280
|
# cwd is set via subprocess.Popen(cwd=…) so codex inherits the right
|
|
1253
|
-
# directory regardless of how we resume.
|
|
1254
|
-
#
|
|
1255
|
-
|
|
1281
|
+
# directory regardless of how we resume. Keep the supported config
|
|
1282
|
+
# override before the positional prompt so saved or explicitly narrowed
|
|
1283
|
+
# continuation policies are enforced on the resumed turn.
|
|
1284
|
+
resume_sandbox_opts = ["-c", f"sandbox_mode={json.dumps(sandbox)}"]
|
|
1285
|
+
options = [
|
|
1286
|
+
*shared_opts,
|
|
1287
|
+
*reasoning_args,
|
|
1288
|
+
*agent.extra_args,
|
|
1289
|
+
*resume_sandbox_opts,
|
|
1290
|
+
]
|
|
1256
1291
|
if _CODEX_UUID_RE.match(agent.session_id):
|
|
1257
1292
|
# Pin to the UUID we parsed from a prior turn's stderr. This is
|
|
1258
1293
|
# robust to parallel codex sessions sharing the cwd because
|
|
@@ -1286,7 +1321,8 @@ def call_codex(agent: Agent, system_prompt: str, message: str,
|
|
|
1286
1321
|
# were already carrying; finally fall back to the "codex-current"
|
|
1287
1322
|
# sentinel so the next turn at least knows a prior turn happened.
|
|
1288
1323
|
parsed_sid = _parse_codex_session_id(err)
|
|
1289
|
-
|
|
1324
|
+
session_pin = _resolve_codex_session_pin(agent, parsed_sid)
|
|
1325
|
+
return out.rstrip(), session_pin or "codex-current"
|
|
1290
1326
|
|
|
1291
1327
|
|
|
1292
1328
|
def _parse_gemini_session_id(stdout: str) -> Optional[str]:
|
|
@@ -1803,6 +1839,50 @@ def call_agent(agent: Agent, message: str, cfg: DuetConfig, first_turn_for_agent
|
|
|
1803
1839
|
return text
|
|
1804
1840
|
|
|
1805
1841
|
|
|
1842
|
+
def _bounded_agent_error_excerpt(
|
|
1843
|
+
text: str,
|
|
1844
|
+
max_chars: int = AGENT_ERROR_TRANSCRIPT_MAX_CHARS,
|
|
1845
|
+
) -> str:
|
|
1846
|
+
"""Keep the useful edges of an error without bloating the transcript."""
|
|
1847
|
+
if max_chars <= 0:
|
|
1848
|
+
return ""
|
|
1849
|
+
if len(text) <= max_chars:
|
|
1850
|
+
return text
|
|
1851
|
+
|
|
1852
|
+
omitted = len(text)
|
|
1853
|
+
while True:
|
|
1854
|
+
marker = (
|
|
1855
|
+
f"\n\n[duet] error text truncated: {omitted} characters omitted; "
|
|
1856
|
+
"full subprocess stderr remains in the log below.\n\n"
|
|
1857
|
+
)
|
|
1858
|
+
excerpt_chars = max_chars - len(marker)
|
|
1859
|
+
if excerpt_chars <= 0:
|
|
1860
|
+
fallback = (
|
|
1861
|
+
f"[duet] error text truncated: {len(text)} characters omitted"
|
|
1862
|
+
)
|
|
1863
|
+
return fallback[:max_chars]
|
|
1864
|
+
head_chars = (excerpt_chars + 1) // 2
|
|
1865
|
+
tail_chars = excerpt_chars // 2
|
|
1866
|
+
new_omitted = len(text) - head_chars - tail_chars
|
|
1867
|
+
if new_omitted == omitted:
|
|
1868
|
+
tail = text[-tail_chars:] if tail_chars else ""
|
|
1869
|
+
return text[:head_chars] + marker + tail
|
|
1870
|
+
omitted = new_omitted
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def format_agent_error_for_transcript(
|
|
1874
|
+
exc: Exception,
|
|
1875
|
+
stderr_log_path: pathlib.Path,
|
|
1876
|
+
max_chars: int = AGENT_ERROR_TRANSCRIPT_MAX_CHARS,
|
|
1877
|
+
) -> str:
|
|
1878
|
+
"""Format bounded error context while pointing at the complete stderr."""
|
|
1879
|
+
excerpt = _bounded_agent_error_excerpt(str(exc), max_chars)
|
|
1880
|
+
return "\n".join([
|
|
1881
|
+
f"[duet] error: {excerpt}",
|
|
1882
|
+
f"[duet] stderr log: {stderr_log_path}",
|
|
1883
|
+
])
|
|
1884
|
+
|
|
1885
|
+
|
|
1806
1886
|
def _agent_failure_block(reason: str, exc: Exception, turn_label: str,
|
|
1807
1887
|
agent: Agent, run_dir: pathlib.Path) -> str:
|
|
1808
1888
|
kind = "TIMEOUT" if reason == FINISHED_TIMEOUT else "AGENT ERROR"
|
|
@@ -1811,8 +1891,7 @@ def _agent_failure_block(reason: str, exc: Exception, turn_label: str,
|
|
|
1811
1891
|
f"[duet] {kind}: turn {turn_label} failed for "
|
|
1812
1892
|
f"{agent.name} ({agent.backend}/{agent.role})",
|
|
1813
1893
|
f"[duet] finished_reason: {reason}",
|
|
1814
|
-
|
|
1815
|
-
f"[duet] stderr log: {log_path}",
|
|
1894
|
+
format_agent_error_for_transcript(exc, log_path),
|
|
1816
1895
|
])
|
|
1817
1896
|
|
|
1818
1897
|
# ---------- loop ----------
|
|
@@ -2078,7 +2157,8 @@ def _setup_run_worktree(
|
|
|
2078
2157
|
try:
|
|
2079
2158
|
r = subprocess.run(
|
|
2080
2159
|
["git", "-C", str(existing), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
2081
|
-
capture_output=True, text=True,
|
|
2160
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
2161
|
+
timeout=5,
|
|
2082
2162
|
)
|
|
2083
2163
|
wt_branch = r.stdout.strip() if r.returncode == 0 else None
|
|
2084
2164
|
except Exception:
|
|
@@ -2135,11 +2215,18 @@ def _build_run_state(cfg: DuetConfig, *, turns_used: int, history: list,
|
|
|
2135
2215
|
"history": history,
|
|
2136
2216
|
"finished_reason": finished_reason,
|
|
2137
2217
|
"transcript_path": str(transcript_path),
|
|
2218
|
+
"sentinel": cfg.sentinel,
|
|
2219
|
+
"sandbox": cfg.sandbox,
|
|
2220
|
+
"permission_mode": cfg.permission_mode,
|
|
2221
|
+
"per_turn_timeout": cfg.per_turn_timeout,
|
|
2138
2222
|
"verify_cmd": cfg.verify_cmd,
|
|
2139
2223
|
"last_verify": last_verify,
|
|
2140
2224
|
"worktree": str(wt_path) if wt_path else None,
|
|
2141
2225
|
"worktree_branch": wt_branch,
|
|
2142
2226
|
"worktree_for": cfg.worktree_for,
|
|
2227
|
+
"add_dirs": [str(d) for d in cfg.add_dirs],
|
|
2228
|
+
"reasoning": cfg.reasoning,
|
|
2229
|
+
"codex_fast": cfg.codex_fast,
|
|
2143
2230
|
"continue_from": cfg.continue_from,
|
|
2144
2231
|
"duet_pid": os.getpid(),
|
|
2145
2232
|
}
|
|
@@ -2702,6 +2789,121 @@ def normalize_verify_cmd(value, parser: argparse.ArgumentParser) -> Optional[str
|
|
|
2702
2789
|
return cmd
|
|
2703
2790
|
|
|
2704
2791
|
|
|
2792
|
+
def _state_int_or_arg(arg_value: Optional[int],
|
|
2793
|
+
state: dict,
|
|
2794
|
+
key: str,
|
|
2795
|
+
default: int,
|
|
2796
|
+
parser: argparse.ArgumentParser,
|
|
2797
|
+
option_name: str) -> int:
|
|
2798
|
+
if arg_value is not None:
|
|
2799
|
+
return arg_value
|
|
2800
|
+
value = state.get(key, default)
|
|
2801
|
+
if isinstance(value, bool):
|
|
2802
|
+
parser.error(f"{option_name}: state.json field {key!r} must be an integer")
|
|
2803
|
+
if isinstance(value, float) and not value.is_integer():
|
|
2804
|
+
parser.error(f"{option_name}: state.json field {key!r} must be an integer")
|
|
2805
|
+
try:
|
|
2806
|
+
return int(value)
|
|
2807
|
+
except (TypeError, ValueError):
|
|
2808
|
+
parser.error(f"{option_name}: state.json field {key!r} must be an integer")
|
|
2809
|
+
raise AssertionError("parser.error should have exited")
|
|
2810
|
+
|
|
2811
|
+
|
|
2812
|
+
def _state_str_or_arg(arg_value: Optional[str],
|
|
2813
|
+
state: dict,
|
|
2814
|
+
key: str,
|
|
2815
|
+
default: str,
|
|
2816
|
+
parser: argparse.ArgumentParser,
|
|
2817
|
+
option_name: str) -> str:
|
|
2818
|
+
if arg_value is not None:
|
|
2819
|
+
return arg_value
|
|
2820
|
+
value = state.get(key, default)
|
|
2821
|
+
if not isinstance(value, str):
|
|
2822
|
+
parser.error(f"{option_name}: state.json field {key!r} must be a string")
|
|
2823
|
+
return value
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
def _state_list_or_arg(arg_value: Optional[list[str]],
|
|
2827
|
+
state: dict,
|
|
2828
|
+
key: str,
|
|
2829
|
+
parser: argparse.ArgumentParser,
|
|
2830
|
+
option_name: str) -> list[str]:
|
|
2831
|
+
if arg_value is not None:
|
|
2832
|
+
return arg_value
|
|
2833
|
+
value = state.get(key, [])
|
|
2834
|
+
if not isinstance(value, list):
|
|
2835
|
+
parser.error(f"{option_name}: state.json field {key!r} must be a list")
|
|
2836
|
+
return [str(item) for item in value]
|
|
2837
|
+
|
|
2838
|
+
|
|
2839
|
+
def _state_extra_args(agents: list[Agent]) -> list[tuple[str, list[str]]]:
|
|
2840
|
+
return [(a.name, list(a.extra_args)) for a in agents if a.extra_args]
|
|
2841
|
+
|
|
2842
|
+
|
|
2843
|
+
def _state_authority_replays(args: argparse.Namespace,
|
|
2844
|
+
sandbox: str,
|
|
2845
|
+
permission_mode: str,
|
|
2846
|
+
add_dirs: list[str]) -> list[tuple[str, object]]:
|
|
2847
|
+
"""Return state-sourced settings that can widen a continued agent's authority."""
|
|
2848
|
+
restored: list[tuple[str, object]] = []
|
|
2849
|
+
if args.sandbox is None and sandbox not in SAFE_CONTINUE_SANDBOXES:
|
|
2850
|
+
restored.append(("sandbox", sandbox))
|
|
2851
|
+
if (args.permission_mode is None
|
|
2852
|
+
and permission_mode not in SAFE_CONTINUE_PERMISSION_MODES):
|
|
2853
|
+
restored.append(("permission_mode", permission_mode))
|
|
2854
|
+
if args.add_dirs is None and add_dirs:
|
|
2855
|
+
restored.append(("add_dirs", add_dirs))
|
|
2856
|
+
return restored
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def _guard_continue_state_replay(state: dict,
|
|
2860
|
+
agents: list[Agent],
|
|
2861
|
+
args: argparse.Namespace,
|
|
2862
|
+
parser: argparse.ArgumentParser,
|
|
2863
|
+
*,
|
|
2864
|
+
sandbox: str,
|
|
2865
|
+
permission_mode: str,
|
|
2866
|
+
add_dirs: list[str]) -> None:
|
|
2867
|
+
"""Require trust for executable or authority-widening state.json fields."""
|
|
2868
|
+
restored_verify = args.verify_cmd is None and bool(state.get("verify_cmd"))
|
|
2869
|
+
restored_extra_args = _state_extra_args(agents)
|
|
2870
|
+
authority_replays = _state_authority_replays(
|
|
2871
|
+
args, sandbox, permission_mode, add_dirs
|
|
2872
|
+
)
|
|
2873
|
+
protected_fields: list[str] = []
|
|
2874
|
+
if restored_verify:
|
|
2875
|
+
protected_fields.append("verify_cmd")
|
|
2876
|
+
if restored_extra_args:
|
|
2877
|
+
protected_fields.append("agent extra_args")
|
|
2878
|
+
protected_fields.extend(name for name, _ in authority_replays)
|
|
2879
|
+
if not protected_fields:
|
|
2880
|
+
return
|
|
2881
|
+
if not args.trust_state and not args.dry_run:
|
|
2882
|
+
parser.error(
|
|
2883
|
+
"--continue: refusing to replay state-sourced "
|
|
2884
|
+
+ ", ".join(protected_fields)
|
|
2885
|
+
+ "; pass --trust-state after inspecting state.json, or provide "
|
|
2886
|
+
"fresh CLI overrides for the saved settings"
|
|
2887
|
+
)
|
|
2888
|
+
if args.trust_state:
|
|
2889
|
+
if restored_verify:
|
|
2890
|
+
print(
|
|
2891
|
+
f"[duet] trusting verify_cmd from state.json: {state.get('verify_cmd')}",
|
|
2892
|
+
file=sys.stderr,
|
|
2893
|
+
)
|
|
2894
|
+
for name, extra_args in restored_extra_args:
|
|
2895
|
+
print(
|
|
2896
|
+
f"[duet] trusting extra_args from state.json for {name}: "
|
|
2897
|
+
f"{shlex.join(extra_args)}",
|
|
2898
|
+
file=sys.stderr,
|
|
2899
|
+
)
|
|
2900
|
+
for name, value in authority_replays:
|
|
2901
|
+
print(
|
|
2902
|
+
f"[duet] trusting {name} from state.json: {value!r}",
|
|
2903
|
+
file=sys.stderr,
|
|
2904
|
+
)
|
|
2905
|
+
|
|
2906
|
+
|
|
2705
2907
|
def _slot_name(backend: str, idx: int) -> str:
|
|
2706
2908
|
slot = "lead" if idx == 0 else "partner"
|
|
2707
2909
|
return f"{backend}-{slot}"
|
|
@@ -2831,7 +3033,7 @@ def apply_resume_overrides(
|
|
|
2831
3033
|
|
|
2832
3034
|
|
|
2833
3035
|
def load_yaml_or_json(path: pathlib.Path) -> dict:
|
|
2834
|
-
text = path.read_text()
|
|
3036
|
+
text = path.read_text(encoding="utf-8")
|
|
2835
3037
|
if path.suffix in {".yaml", ".yml"}:
|
|
2836
3038
|
try:
|
|
2837
3039
|
import yaml # type: ignore
|
|
@@ -2989,6 +3191,31 @@ def _pid_alive(pid: int) -> bool:
|
|
|
2989
3191
|
return True
|
|
2990
3192
|
|
|
2991
3193
|
|
|
3194
|
+
def _coerce_pid(value: object) -> Optional[int]:
|
|
3195
|
+
if isinstance(value, bool):
|
|
3196
|
+
return None
|
|
3197
|
+
if isinstance(value, float) and not value.is_integer():
|
|
3198
|
+
return None
|
|
3199
|
+
try:
|
|
3200
|
+
pid = int(value) # type: ignore[arg-type]
|
|
3201
|
+
except (TypeError, ValueError):
|
|
3202
|
+
return None
|
|
3203
|
+
return pid if pid > 0 else None
|
|
3204
|
+
|
|
3205
|
+
|
|
3206
|
+
def _pid_file_snapshot(pid_file: pathlib.Path) -> Optional[tuple[Optional[int], dt.datetime]]:
|
|
3207
|
+
"""Read a transient pid file; return None if it disappears mid-status."""
|
|
3208
|
+
try:
|
|
3209
|
+
text = pid_file.read_text(encoding="utf-8").strip()
|
|
3210
|
+
stat = pid_file.stat()
|
|
3211
|
+
except FileNotFoundError:
|
|
3212
|
+
return None
|
|
3213
|
+
except OSError as e:
|
|
3214
|
+
print(f"[duet] unable to read pid file {pid_file}: {e}", file=sys.stderr)
|
|
3215
|
+
return None
|
|
3216
|
+
return _coerce_pid(text), dt.datetime.fromtimestamp(stat.st_mtime)
|
|
3217
|
+
|
|
3218
|
+
|
|
2992
3219
|
def _proc_cmdline(pid: int) -> Optional[str]:
|
|
2993
3220
|
"""Best-effort read of a PID's full cmdline. Returns None on any failure.
|
|
2994
3221
|
|
|
@@ -3004,8 +3231,11 @@ def _proc_cmdline(pid: int) -> Optional[str]:
|
|
|
3004
3231
|
return None
|
|
3005
3232
|
# macOS / BSD: shell out to ps. Cheap, ~5ms.
|
|
3006
3233
|
try:
|
|
3007
|
-
r = subprocess.run(
|
|
3008
|
-
|
|
3234
|
+
r = subprocess.run(
|
|
3235
|
+
["ps", "-o", "command=", "-p", str(pid)],
|
|
3236
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
3237
|
+
timeout=2,
|
|
3238
|
+
)
|
|
3009
3239
|
if r.returncode == 0 and r.stdout.strip():
|
|
3010
3240
|
return r.stdout.strip()
|
|
3011
3241
|
except (subprocess.TimeoutExpired, OSError):
|
|
@@ -3050,7 +3280,7 @@ def print_run_status(arg: str) -> int:
|
|
|
3050
3280
|
state: dict = {}
|
|
3051
3281
|
if state_path.exists():
|
|
3052
3282
|
try:
|
|
3053
|
-
state = json.loads(state_path.read_text())
|
|
3283
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
3054
3284
|
except json.JSONDecodeError as e:
|
|
3055
3285
|
print(f"[duet] state.json malformed: {e}", file=sys.stderr)
|
|
3056
3286
|
return 3
|
|
@@ -3069,31 +3299,33 @@ def print_run_status(arg: str) -> int:
|
|
|
3069
3299
|
pid_files = sorted(run_dir.glob("turn-*.pid"))
|
|
3070
3300
|
if pid_files:
|
|
3071
3301
|
pid_file = pid_files[-1]
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3302
|
+
snapshot = _pid_file_snapshot(pid_file)
|
|
3303
|
+
if snapshot is not None:
|
|
3304
|
+
pid, started_at = snapshot
|
|
3305
|
+
# Filename: turn-<label>-<agent>.pid
|
|
3306
|
+
stem = pid_file.stem # turn-02-claude-planner
|
|
3307
|
+
elapsed = (dt.datetime.now() - started_at).total_seconds()
|
|
3308
|
+
alive = _pid_alive(pid) if pid is not None else False
|
|
3309
|
+
print(f" in-flight turn: {stem}")
|
|
3310
|
+
print(f" pid: {pid} (alive: {alive})")
|
|
3311
|
+
print(f" started: {started_at.isoformat(timespec='seconds')} "
|
|
3312
|
+
f"({int(elapsed)}s ago)")
|
|
3313
|
+
# Heartbeat from the matching stderr log
|
|
3314
|
+
log = run_dir / f"{stem}.stderr.log"
|
|
3315
|
+
try:
|
|
3316
|
+
log_stat = log.stat()
|
|
3317
|
+
except OSError:
|
|
3318
|
+
log_stat = None
|
|
3319
|
+
if log_stat is not None:
|
|
3320
|
+
log_age = (dt.datetime.now()
|
|
3321
|
+
- dt.datetime.fromtimestamp(log_stat.st_mtime)).total_seconds()
|
|
3322
|
+
print(f" last stderr: {int(log_age)}s ago "
|
|
3323
|
+
f"({log_stat.st_size} bytes)")
|
|
3324
|
+
if not alive:
|
|
3325
|
+
print(" ⚠ pid file present but process is gone — turn likely "
|
|
3326
|
+
"crashed or was killed without cleanup")
|
|
3327
|
+
return 2
|
|
3328
|
+
return 1
|
|
3097
3329
|
# No pid files. Either run hasn't started, has finished, or is between
|
|
3098
3330
|
# turns (in particular, sitting at the post-loop `force>` prompt).
|
|
3099
3331
|
if finished:
|
|
@@ -3104,7 +3336,12 @@ def print_run_status(arg: str) -> int:
|
|
|
3104
3336
|
# duet_pid recorded in state.json.
|
|
3105
3337
|
duet_pid = state.get("duet_pid")
|
|
3106
3338
|
if duet_pid is not None:
|
|
3107
|
-
|
|
3339
|
+
pid = _coerce_pid(duet_pid)
|
|
3340
|
+
if pid is None:
|
|
3341
|
+
print(f" ⚠ invalid duet_pid {duet_pid!r}; no valid liveness "
|
|
3342
|
+
"proof recorded")
|
|
3343
|
+
return 2
|
|
3344
|
+
if _is_duet_process(pid):
|
|
3108
3345
|
print(f" state: between turns / awaiting force> prompt")
|
|
3109
3346
|
print(f" duet pid: {duet_pid} (alive)")
|
|
3110
3347
|
history = state.get("history") or []
|
|
@@ -3128,6 +3365,18 @@ def print_run_status(arg: str) -> int:
|
|
|
3128
3365
|
return 2
|
|
3129
3366
|
|
|
3130
3367
|
|
|
3368
|
+
def print_run_status_safe(arg: str) -> int:
|
|
3369
|
+
try:
|
|
3370
|
+
return print_run_status(arg)
|
|
3371
|
+
except Exception as e:
|
|
3372
|
+
print(
|
|
3373
|
+
f"[duet] status failed ({type(e).__name__}): {e}",
|
|
3374
|
+
file=sys.stderr,
|
|
3375
|
+
)
|
|
3376
|
+
traceback.print_exc(file=sys.stderr)
|
|
3377
|
+
return 3
|
|
3378
|
+
|
|
3379
|
+
|
|
3131
3380
|
# ---------- run-list (`duet --list [PATH]`) ----------
|
|
3132
3381
|
|
|
3133
3382
|
# Status glyphs — same vocabulary as print_run_status, packed for table cols.
|
|
@@ -3214,7 +3463,7 @@ def _load_run_state(run_dir: pathlib.Path,
|
|
|
3214
3463
|
if not state_path.is_file():
|
|
3215
3464
|
parser.error(f"{option_name}: missing state.json in {run_dir}")
|
|
3216
3465
|
try:
|
|
3217
|
-
return json.loads(state_path.read_text())
|
|
3466
|
+
return json.loads(state_path.read_text(encoding="utf-8"))
|
|
3218
3467
|
except json.JSONDecodeError as e:
|
|
3219
3468
|
parser.error(f"{option_name}: state.json malformed: {e}")
|
|
3220
3469
|
except OSError as e:
|
|
@@ -3348,6 +3597,28 @@ def build_continue_config(run_arg: str,
|
|
|
3348
3597
|
parser.error(f"--continue: no such run dir or id: {run_arg}")
|
|
3349
3598
|
state = _load_run_state(run_dir, parser, "--continue")
|
|
3350
3599
|
agents = _agents_from_state(state, parser, "--continue")
|
|
3600
|
+
sentinel = _state_str_or_arg(
|
|
3601
|
+
args.sentinel, state, "sentinel", DEFAULT_SENTINEL, parser, "--continue"
|
|
3602
|
+
)
|
|
3603
|
+
sandbox = _state_str_or_arg(
|
|
3604
|
+
args.sandbox, state, "sandbox", DEFAULT_SANDBOX, parser, "--continue"
|
|
3605
|
+
)
|
|
3606
|
+
permission_mode = _state_str_or_arg(
|
|
3607
|
+
args.permission_mode, state, "permission_mode", DEFAULT_PERMISSION_MODE,
|
|
3608
|
+
parser, "--continue",
|
|
3609
|
+
)
|
|
3610
|
+
add_dirs = _state_list_or_arg(
|
|
3611
|
+
args.add_dirs, state, "add_dirs", parser, "--continue"
|
|
3612
|
+
)
|
|
3613
|
+
_guard_continue_state_replay(
|
|
3614
|
+
state,
|
|
3615
|
+
agents,
|
|
3616
|
+
args,
|
|
3617
|
+
parser,
|
|
3618
|
+
sandbox=sandbox,
|
|
3619
|
+
permission_mode=permission_mode,
|
|
3620
|
+
add_dirs=add_dirs,
|
|
3621
|
+
)
|
|
3351
3622
|
# Older runs (or runs that crashed before the first state.json roll) may
|
|
3352
3623
|
# have Codex agents that already spoke but have no saved session_id. Without
|
|
3353
3624
|
# a marker, run_duet would treat the next turn as a fresh `codex exec` and
|
|
@@ -3363,7 +3634,10 @@ def build_continue_config(run_arg: str,
|
|
|
3363
3634
|
and agent.name in codex_speakers):
|
|
3364
3635
|
agent.session_id = "codex-current"
|
|
3365
3636
|
cwd = pathlib.Path(state.get("cwd") or ".").expanduser().resolve()
|
|
3366
|
-
timeout =
|
|
3637
|
+
timeout = _state_int_or_arg(
|
|
3638
|
+
args.timeout, state, "per_turn_timeout", DEFAULT_TIMEOUT,
|
|
3639
|
+
parser, "--continue",
|
|
3640
|
+
)
|
|
3367
3641
|
user_note = _continue_note_from_args(args, cwd, timeout, parser, stdin_cache)
|
|
3368
3642
|
next_idx = _next_speaker_idx_from_state(agents, state)
|
|
3369
3643
|
|
|
@@ -3385,11 +3659,11 @@ def build_continue_config(run_arg: str,
|
|
|
3385
3659
|
task=state.get("task"),
|
|
3386
3660
|
kickoff=kickoff,
|
|
3387
3661
|
max_turns=args.turns,
|
|
3388
|
-
sentinel=
|
|
3662
|
+
sentinel=sentinel,
|
|
3389
3663
|
per_turn_timeout=timeout,
|
|
3390
3664
|
runs_dir=runs_dir,
|
|
3391
|
-
sandbox=
|
|
3392
|
-
permission_mode=
|
|
3665
|
+
sandbox=sandbox,
|
|
3666
|
+
permission_mode=permission_mode,
|
|
3393
3667
|
dry_run=args.dry_run,
|
|
3394
3668
|
recap=args.recap or bool(state.get("recap_path")),
|
|
3395
3669
|
verify_cmd=normalize_verify_cmd(
|
|
@@ -3399,9 +3673,15 @@ def build_continue_config(run_arg: str,
|
|
|
3399
3673
|
worktree=False,
|
|
3400
3674
|
worktree_for=worktree_for,
|
|
3401
3675
|
worktree_path=worktree_path,
|
|
3402
|
-
add_dirs=[
|
|
3403
|
-
|
|
3404
|
-
|
|
3676
|
+
add_dirs=[
|
|
3677
|
+
pathlib.Path(d).expanduser().resolve()
|
|
3678
|
+
for d in add_dirs
|
|
3679
|
+
],
|
|
3680
|
+
reasoning=args.reasoning if args.reasoning is not None else state.get("reasoning"),
|
|
3681
|
+
codex_fast=bool(
|
|
3682
|
+
args.codex_fast
|
|
3683
|
+
if args.codex_fast is not None else state.get("codex_fast", False)
|
|
3684
|
+
),
|
|
3405
3685
|
start_speaker_idx=next_idx,
|
|
3406
3686
|
continue_from=str(run_dir),
|
|
3407
3687
|
)
|
|
@@ -3434,7 +3714,7 @@ def _classify_run(run_dir: pathlib.Path) -> tuple[str, str, dict]:
|
|
|
3434
3714
|
if not state_path.is_file():
|
|
3435
3715
|
return ("❓", "no state.json", {})
|
|
3436
3716
|
try:
|
|
3437
|
-
state = json.loads(state_path.read_text())
|
|
3717
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
3438
3718
|
except json.JSONDecodeError:
|
|
3439
3719
|
return ("❓", "malformed state", {})
|
|
3440
3720
|
finished = state.get("finished_reason")
|
|
@@ -3445,9 +3725,12 @@ def _classify_run(run_dir: pathlib.Path) -> tuple[str, str, dict]:
|
|
|
3445
3725
|
if list(run_dir.glob("turn-*.pid")):
|
|
3446
3726
|
return ("🟢", "in-flight", state)
|
|
3447
3727
|
pid = state.get("duet_pid")
|
|
3448
|
-
if pid is not None and _is_duet_process(int(pid)):
|
|
3449
|
-
return ("🟢", "between turns", state)
|
|
3450
3728
|
if pid is not None:
|
|
3729
|
+
coerced = _coerce_pid(pid)
|
|
3730
|
+
if coerced is None:
|
|
3731
|
+
return ("⚠", "invalid pid", state)
|
|
3732
|
+
if _is_duet_process(coerced):
|
|
3733
|
+
return ("🟢", "between turns", state)
|
|
3451
3734
|
return ("⚠", "duet died", state)
|
|
3452
3735
|
return ("⚠", "stuck (no pid)", state)
|
|
3453
3736
|
|
|
@@ -3557,18 +3840,19 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
3557
3840
|
help="model name to pass to the partner agent's backend via --model")
|
|
3558
3841
|
ap.add_argument("--cwd", default=".", help="working dir for both agents")
|
|
3559
3842
|
ap.add_argument("--turns", type=int, default=DEFAULT_TURNS, help=f"max turns (default {DEFAULT_TURNS})")
|
|
3560
|
-
ap.add_argument("--sentinel", default=
|
|
3843
|
+
ap.add_argument("--sentinel", default=None,
|
|
3561
3844
|
help="convergence sentinel; requires an LGTM rationale and "
|
|
3562
3845
|
"back-to-back proposals from both agents")
|
|
3563
|
-
ap.add_argument("--timeout", type=int, default=
|
|
3846
|
+
ap.add_argument("--timeout", type=int, default=None,
|
|
3847
|
+
help=f"per-turn timeout seconds (default {DEFAULT_TIMEOUT})")
|
|
3564
3848
|
ap.add_argument("--verify-cmd", metavar="CMD", default=None,
|
|
3565
3849
|
help="shell command that must exit 0 before a convergence "
|
|
3566
3850
|
"proposal can count. Runs only for valid LGTM+rationale "
|
|
3567
3851
|
"proposals; YAML key: `verify_cmd:`.")
|
|
3568
3852
|
ap.add_argument("--runs-dir", default=None, help="where to save transcripts")
|
|
3569
|
-
ap.add_argument("--sandbox", default=
|
|
3853
|
+
ap.add_argument("--sandbox", default=None,
|
|
3570
3854
|
help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot/opencode.")
|
|
3571
|
-
ap.add_argument("--permission-mode", default=
|
|
3855
|
+
ap.add_argument("--permission-mode", default=None,
|
|
3572
3856
|
help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot/opencode.")
|
|
3573
3857
|
ap.add_argument("--config", help="optional YAML/JSON config (overrides flags except --resume-*)")
|
|
3574
3858
|
ap.add_argument("--worktree", action="store_true",
|
|
@@ -3586,7 +3870,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
3586
3870
|
"Each run lands at <PATH>/<run_id>/. Default: <runs_dir>/<run_id>/wt/, "
|
|
3587
3871
|
"which is durable across reboots and OS temp-dir cleaners. "
|
|
3588
3872
|
"Pass /tmp or $TMPDIR to mimic the pre-fix throwaway behavior.")
|
|
3589
|
-
ap.add_argument("--add-dir", action="append", metavar="PATH", default=
|
|
3873
|
+
ap.add_argument("--add-dir", action="append", metavar="PATH", default=None,
|
|
3590
3874
|
dest="add_dirs",
|
|
3591
3875
|
help="extra path claude/gemini/copilot may access outside cwd. "
|
|
3592
3876
|
"Repeatable. Claude and Copilot receive --add-dir; "
|
|
@@ -3599,14 +3883,24 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
3599
3883
|
"(minimal → low) and adds high/xhigh/max prompt nudges. "
|
|
3600
3884
|
"Copilot: passes `--effort <v>` (minimal → none). "
|
|
3601
3885
|
"Gemini: no effort flag; high/xhigh/max are prompt nudges only.")
|
|
3602
|
-
ap.
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3886
|
+
codex_fast_group = ap.add_mutually_exclusive_group()
|
|
3887
|
+
codex_fast_group.add_argument(
|
|
3888
|
+
"--codex-fast", action="store_true", dest="codex_fast",
|
|
3889
|
+
help="Codex-only fast mode: pin codex coder turns to "
|
|
3890
|
+
"`model_reasoning_effort=low` and "
|
|
3891
|
+
"`model_reasoning_summary=concise`, regardless of --reasoning / "
|
|
3892
|
+
"per-agent reasoning_effort. YAML key: `codex_fast: true`.",
|
|
3893
|
+
)
|
|
3894
|
+
codex_fast_group.add_argument(
|
|
3895
|
+
"--no-codex-fast", action="store_false", dest="codex_fast",
|
|
3896
|
+
help="disable codex fast mode, including a value restored by "
|
|
3897
|
+
"--continue or enabled by a YAML/JSON config",
|
|
3898
|
+
)
|
|
3899
|
+
ap.set_defaults(codex_fast=None)
|
|
3900
|
+
ap.add_argument("--trust-state", action="store_true", dest="trust_state",
|
|
3901
|
+
help="with --continue, allow state.json-sourced commands "
|
|
3902
|
+
"and authority-widening sandbox, permission, or "
|
|
3903
|
+
"add-dir settings after inspecting the run directory")
|
|
3610
3904
|
ap.add_argument("--status", metavar="RUN_DIR_OR_ID", default=None,
|
|
3611
3905
|
help="don't run a duet — instead print a one-shot health "
|
|
3612
3906
|
"summary of an existing run and exit. Accepts a path "
|
|
@@ -3647,10 +3941,13 @@ def _build_cfg_from_yaml(args: argparse.Namespace, ap: argparse.ArgumentParser,
|
|
|
3647
3941
|
"""Build a DuetConfig from a --config YAML/JSON file. CLI flags only fill
|
|
3648
3942
|
seed inputs (task/kickoff) when the file specifies none, and a handful of
|
|
3649
3943
|
flags (runs_dir, verify_cmd, worktree*, recap, reasoning, codex_fast)
|
|
3650
|
-
override or
|
|
3944
|
+
override or combine with file values; --resume-* still apply on top."""
|
|
3651
3945
|
raw = load_yaml_or_json(pathlib.Path(args.config))
|
|
3652
3946
|
cfg_cwd = pathlib.Path(raw.get("cwd", ".")).expanduser().resolve()
|
|
3653
|
-
cfg_timeout = int(raw.get(
|
|
3947
|
+
cfg_timeout = int(raw.get(
|
|
3948
|
+
"per_turn_timeout",
|
|
3949
|
+
args.timeout if args.timeout is not None else DEFAULT_TIMEOUT,
|
|
3950
|
+
))
|
|
3654
3951
|
raw_task = raw.get("task")
|
|
3655
3952
|
raw_kickoff = raw.get("kickoff")
|
|
3656
3953
|
raw_task_from_cmd = raw.get("task_from_cmd")
|
|
@@ -3681,11 +3978,21 @@ def _build_cfg_from_yaml(args: argparse.Namespace, ap: argparse.ArgumentParser,
|
|
|
3681
3978
|
task=task,
|
|
3682
3979
|
kickoff=kickoff,
|
|
3683
3980
|
max_turns=int(raw.get("max_turns", DEFAULT_TURNS)),
|
|
3684
|
-
sentinel=raw.get(
|
|
3981
|
+
sentinel=raw.get(
|
|
3982
|
+
"sentinel",
|
|
3983
|
+
args.sentinel if args.sentinel is not None else DEFAULT_SENTINEL,
|
|
3984
|
+
),
|
|
3685
3985
|
per_turn_timeout=cfg_timeout,
|
|
3686
3986
|
runs_dir=choose_runs_dir(raw_runs_dir, cfg_cwd),
|
|
3687
|
-
sandbox=raw.get(
|
|
3688
|
-
|
|
3987
|
+
sandbox=raw.get(
|
|
3988
|
+
"sandbox",
|
|
3989
|
+
args.sandbox if args.sandbox is not None else DEFAULT_SANDBOX,
|
|
3990
|
+
),
|
|
3991
|
+
permission_mode=raw.get(
|
|
3992
|
+
"permission_mode",
|
|
3993
|
+
args.permission_mode
|
|
3994
|
+
if args.permission_mode is not None else DEFAULT_PERMISSION_MODE,
|
|
3995
|
+
),
|
|
3689
3996
|
dry_run=bool(raw.get("dry_run", False)),
|
|
3690
3997
|
recap=bool(raw.get("recap", False)) or args.recap,
|
|
3691
3998
|
verify_cmd=verify_cmd,
|
|
@@ -3695,10 +4002,16 @@ def _build_cfg_from_yaml(args: argparse.Namespace, ap: argparse.ArgumentParser,
|
|
|
3695
4002
|
worktree_root=_resolve_opt_path(args.worktree_root, raw.get("worktree_root")),
|
|
3696
4003
|
add_dirs=[
|
|
3697
4004
|
pathlib.Path(d).expanduser().resolve()
|
|
3698
|
-
for d in (
|
|
4005
|
+
for d in (
|
|
4006
|
+
args.add_dirs if args.add_dirs is not None
|
|
4007
|
+
else raw.get("add_dirs", [])
|
|
4008
|
+
)
|
|
3699
4009
|
],
|
|
3700
4010
|
reasoning=args.reasoning or raw.get("reasoning"),
|
|
3701
|
-
codex_fast=bool(
|
|
4011
|
+
codex_fast=bool(
|
|
4012
|
+
args.codex_fast
|
|
4013
|
+
if args.codex_fast is not None else raw.get("codex_fast", False)
|
|
4014
|
+
),
|
|
3702
4015
|
)
|
|
3703
4016
|
cfg.agents = apply_resume_overrides(
|
|
3704
4017
|
cfg.agents,
|
|
@@ -3717,12 +4030,13 @@ def _build_cfg_from_cli(args: argparse.Namespace, ap: argparse.ArgumentParser,
|
|
|
3717
4030
|
agent in the "wrong" slot still routes its session id correctly.
|
|
3718
4031
|
"""
|
|
3719
4032
|
cfg_cwd = pathlib.Path(args.cwd).expanduser().resolve()
|
|
4033
|
+
timeout = args.timeout if args.timeout is not None else DEFAULT_TIMEOUT
|
|
3720
4034
|
task, kickoff = resolve_seed_inputs(
|
|
3721
4035
|
task=args.task,
|
|
3722
4036
|
kickoff=args.kickoff,
|
|
3723
4037
|
task_from_cmd=args.task_from_cmd,
|
|
3724
4038
|
cwd=cfg_cwd,
|
|
3725
|
-
timeout=
|
|
4039
|
+
timeout=timeout,
|
|
3726
4040
|
parser=ap,
|
|
3727
4041
|
stdin_cache=stdin_cache,
|
|
3728
4042
|
)
|
|
@@ -3748,11 +4062,14 @@ def _build_cfg_from_cli(args: argparse.Namespace, ap: argparse.ArgumentParser,
|
|
|
3748
4062
|
task=task,
|
|
3749
4063
|
kickoff=kickoff,
|
|
3750
4064
|
max_turns=args.turns,
|
|
3751
|
-
sentinel=args.sentinel,
|
|
3752
|
-
per_turn_timeout=
|
|
4065
|
+
sentinel=args.sentinel if args.sentinel is not None else DEFAULT_SENTINEL,
|
|
4066
|
+
per_turn_timeout=timeout,
|
|
3753
4067
|
runs_dir=choose_runs_dir(args.runs_dir, cfg_cwd),
|
|
3754
|
-
sandbox=args.sandbox,
|
|
3755
|
-
permission_mode=
|
|
4068
|
+
sandbox=args.sandbox if args.sandbox is not None else DEFAULT_SANDBOX,
|
|
4069
|
+
permission_mode=(
|
|
4070
|
+
args.permission_mode
|
|
4071
|
+
if args.permission_mode is not None else DEFAULT_PERMISSION_MODE
|
|
4072
|
+
),
|
|
3756
4073
|
dry_run=args.dry_run,
|
|
3757
4074
|
recap=args.recap,
|
|
3758
4075
|
verify_cmd=normalize_verify_cmd(args.verify_cmd, ap),
|
|
@@ -3760,7 +4077,10 @@ def _build_cfg_from_cli(args: argparse.Namespace, ap: argparse.ArgumentParser,
|
|
|
3760
4077
|
worktree_for=args.worktree_for or "partner",
|
|
3761
4078
|
worktree_path=_resolve_opt_path(args.worktree_path),
|
|
3762
4079
|
worktree_root=_resolve_opt_path(args.worktree_root),
|
|
3763
|
-
add_dirs=[
|
|
4080
|
+
add_dirs=[
|
|
4081
|
+
pathlib.Path(d).expanduser().resolve()
|
|
4082
|
+
for d in (args.add_dirs or [])
|
|
4083
|
+
],
|
|
3764
4084
|
reasoning=args.reasoning,
|
|
3765
4085
|
codex_fast=bool(args.codex_fast),
|
|
3766
4086
|
)
|
|
@@ -3804,7 +4124,7 @@ def main() -> int:
|
|
|
3804
4124
|
|
|
3805
4125
|
# `--status` is read-only: print run health and exit. Skip everything below.
|
|
3806
4126
|
if args.status:
|
|
3807
|
-
return
|
|
4127
|
+
return print_run_status_safe(args.status)
|
|
3808
4128
|
|
|
3809
4129
|
# `--list` is read-only: print the run-dir table and exit.
|
|
3810
4130
|
if args.list_runs is not None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duet-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Two CLI agents in conversation. One Python file. Stdlib only.
|
|
5
5
|
Author: Volkan Altan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -166,6 +166,8 @@ duet --reasoning high --codex-fast \
|
|
|
166
166
|
--task "Fix the issue" --cwd ~/workspace/project
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
Use `--no-codex-fast` to override a config or continued run that enabled it.
|
|
170
|
+
|
|
169
171
|
**Verify gate** — a convergence proposal only counts if `make test` exits 0;
|
|
170
172
|
any failure feeds back into the next turn:
|
|
171
173
|
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
These tests cover the correctness-critical helpers that `scripts/smoke.sh`
|
|
4
4
|
can't observe through exit codes alone: convergence detection, codex/copilot
|
|
5
5
|
session-id parsing, recap header parsing, file-path heuristics, reasoning
|
|
6
|
-
mappings, partner-spec parsing, markdown-fence sizing,
|
|
7
|
-
|
|
6
|
+
mappings, partner-spec parsing, markdown-fence sizing, age formatting, and
|
|
7
|
+
bounded agent-error transcript formatting, continuation-state validation, and
|
|
8
|
+
status fallbacks. They are pure-function tests — no subprocesses, no filesystem
|
|
8
9
|
writes, no agent CLIs.
|
|
9
10
|
|
|
10
11
|
Run via:
|
|
@@ -17,6 +18,7 @@ from __future__ import annotations
|
|
|
17
18
|
import contextlib
|
|
18
19
|
import io
|
|
19
20
|
import pathlib
|
|
21
|
+
import re
|
|
20
22
|
import subprocess
|
|
21
23
|
import sys
|
|
22
24
|
import unittest
|
|
@@ -261,6 +263,30 @@ class TestVerifyHelpers(unittest.TestCase):
|
|
|
261
263
|
# ---------- agent finish reasons ----------
|
|
262
264
|
|
|
263
265
|
|
|
266
|
+
class TestAgentFailureTranscript(unittest.TestCase):
|
|
267
|
+
def test_small_error_is_preserved_with_stderr_link(self) -> None:
|
|
268
|
+
log_path = pathlib.Path("/repo/runs/1/turn-01-codex.stderr.log")
|
|
269
|
+
block = duet.format_agent_error_for_transcript(
|
|
270
|
+
RuntimeError("backend failed"), log_path
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
self.assertIn("[duet] error: backend failed", block)
|
|
274
|
+
self.assertIn(f"[duet] stderr log: {log_path}", block)
|
|
275
|
+
self.assertNotIn("characters omitted", block)
|
|
276
|
+
|
|
277
|
+
def test_large_error_keeps_head_and_tail_with_omitted_count(self) -> None:
|
|
278
|
+
text = "HEAD-" + ("x" * 2000) + "-TAIL"
|
|
279
|
+
excerpt = duet._bounded_agent_error_excerpt(text, max_chars=240)
|
|
280
|
+
|
|
281
|
+
self.assertLessEqual(len(excerpt), 240)
|
|
282
|
+
self.assertTrue(excerpt.startswith("HEAD-"))
|
|
283
|
+
self.assertTrue(excerpt.endswith("-TAIL"))
|
|
284
|
+
match = re.search(r"(\d+) characters omitted", excerpt)
|
|
285
|
+
self.assertIsNotNone(match)
|
|
286
|
+
self.assertGreater(int(match.group(1)), 0)
|
|
287
|
+
self.assertNotIn("x" * 500, excerpt)
|
|
288
|
+
|
|
289
|
+
|
|
264
290
|
class TestAgentFinishReasons(unittest.TestCase):
|
|
265
291
|
def test_codex_rc_124_maps_to_timeout(self) -> None:
|
|
266
292
|
def fake_run(cmd, **kwargs):
|
|
@@ -625,7 +651,12 @@ class TestParseCodexSessionId(unittest.TestCase):
|
|
|
625
651
|
duet._parse_codex_session_id(f"see {self.UUID} for details")
|
|
626
652
|
)
|
|
627
653
|
|
|
628
|
-
def
|
|
654
|
+
def test_inline_session_id_label_rejected(self) -> None:
|
|
655
|
+
self.assertIsNone(
|
|
656
|
+
duet._parse_codex_session_id(f"trace: session id: {self.UUID}\n")
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
def test_first_line_start_match_wins(self) -> None:
|
|
629
660
|
first = "11111111-1111-1111-1111-111111111111"
|
|
630
661
|
second = "22222222-2222-2222-2222-222222222222"
|
|
631
662
|
stderr = (
|
|
@@ -633,7 +664,19 @@ class TestParseCodexSessionId(unittest.TestCase):
|
|
|
633
664
|
"...\n"
|
|
634
665
|
f"session id: {second}\n"
|
|
635
666
|
)
|
|
636
|
-
self.assertEqual(duet._parse_codex_session_id(stderr),
|
|
667
|
+
self.assertEqual(duet._parse_codex_session_id(stderr), first)
|
|
668
|
+
|
|
669
|
+
def test_existing_uuid_pin_not_replaced_by_different_parse(self) -> None:
|
|
670
|
+
existing = "11111111-1111-1111-1111-111111111111"
|
|
671
|
+
parsed = "22222222-2222-2222-2222-222222222222"
|
|
672
|
+
agent = duet.Agent(
|
|
673
|
+
name="codex-partner",
|
|
674
|
+
backend="codex",
|
|
675
|
+
role="coder",
|
|
676
|
+
session_id=existing,
|
|
677
|
+
)
|
|
678
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
679
|
+
self.assertEqual(duet._resolve_codex_session_pin(agent, parsed), existing)
|
|
637
680
|
|
|
638
681
|
def test_uuid_re_pattern_round_trip(self) -> None:
|
|
639
682
|
# Sanity: _CODEX_UUID_RE matches a parsed UUID; rejects the
|
|
@@ -1092,6 +1135,18 @@ class TestBuildCfgFromCli(unittest.TestCase):
|
|
|
1092
1135
|
self.assertIsNone(cfg.agents[0].model)
|
|
1093
1136
|
self.assertIsNone(cfg.agents[1].model)
|
|
1094
1137
|
|
|
1138
|
+
def test_codex_fast_flags_are_tristate_and_mutually_exclusive(self) -> None:
|
|
1139
|
+
_, omitted = self._parse("--task", "x")
|
|
1140
|
+
_, enabled = self._parse("--task", "x", "--codex-fast")
|
|
1141
|
+
_, disabled = self._parse("--task", "x", "--no-codex-fast")
|
|
1142
|
+
|
|
1143
|
+
self.assertIsNone(omitted.codex_fast)
|
|
1144
|
+
self.assertIs(enabled.codex_fast, True)
|
|
1145
|
+
self.assertIs(disabled.codex_fast, False)
|
|
1146
|
+
with self.assertRaises(SystemExit), \
|
|
1147
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
1148
|
+
self._parse("--task", "x", "--codex-fast", "--no-codex-fast")
|
|
1149
|
+
|
|
1095
1150
|
|
|
1096
1151
|
# ---------- apply_resume_overrides ----------
|
|
1097
1152
|
|
|
@@ -1470,6 +1525,140 @@ class TestFormatByteSize(unittest.TestCase):
|
|
|
1470
1525
|
self.assertEqual(duet._format_byte_size(1536), "1.5KB")
|
|
1471
1526
|
|
|
1472
1527
|
|
|
1528
|
+
# ---------- continuation state helpers ----------
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
class TestContinueStateHelpers(unittest.TestCase):
|
|
1532
|
+
def setUp(self) -> None:
|
|
1533
|
+
self.parser = duet.argparse.ArgumentParser()
|
|
1534
|
+
|
|
1535
|
+
def test_state_int_prefers_cli_then_parses_saved_value(self) -> None:
|
|
1536
|
+
self.assertEqual(
|
|
1537
|
+
duet._state_int_or_arg(
|
|
1538
|
+
11, {"timeout": "bad"}, "timeout", 7,
|
|
1539
|
+
self.parser, "--continue",
|
|
1540
|
+
),
|
|
1541
|
+
11,
|
|
1542
|
+
)
|
|
1543
|
+
self.assertEqual(
|
|
1544
|
+
duet._state_int_or_arg(
|
|
1545
|
+
None, {"timeout": "13"}, "timeout", 7,
|
|
1546
|
+
self.parser, "--continue",
|
|
1547
|
+
),
|
|
1548
|
+
13,
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
def test_state_int_rejects_malformed_saved_value(self) -> None:
|
|
1552
|
+
with self.assertRaises(SystemExit), \
|
|
1553
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
1554
|
+
duet._state_int_or_arg(
|
|
1555
|
+
None, {"timeout": "not-an-int"}, "timeout", 7,
|
|
1556
|
+
self.parser, "--continue",
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
def test_state_str_prefers_cli_and_rejects_non_string_state(self) -> None:
|
|
1560
|
+
self.assertEqual(
|
|
1561
|
+
duet._state_str_or_arg(
|
|
1562
|
+
"plan", {"permission_mode": 42}, "permission_mode",
|
|
1563
|
+
duet.DEFAULT_PERMISSION_MODE, self.parser, "--continue",
|
|
1564
|
+
),
|
|
1565
|
+
"plan",
|
|
1566
|
+
)
|
|
1567
|
+
with self.assertRaises(SystemExit), \
|
|
1568
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
1569
|
+
duet._state_str_or_arg(
|
|
1570
|
+
None, {"permission_mode": 42}, "permission_mode",
|
|
1571
|
+
duet.DEFAULT_PERMISSION_MODE, self.parser, "--continue",
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
def test_state_list_prefers_cli_and_rejects_non_list_state(self) -> None:
|
|
1575
|
+
self.assertEqual(
|
|
1576
|
+
duet._state_list_or_arg(
|
|
1577
|
+
["fresh"], {"add_dirs": "not-a-list"}, "add_dirs",
|
|
1578
|
+
self.parser, "--continue",
|
|
1579
|
+
),
|
|
1580
|
+
["fresh"],
|
|
1581
|
+
)
|
|
1582
|
+
with self.assertRaises(SystemExit), \
|
|
1583
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
1584
|
+
duet._state_list_or_arg(
|
|
1585
|
+
None, {"add_dirs": "not-a-list"}, "add_dirs",
|
|
1586
|
+
self.parser, "--continue",
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
def test_authority_widening_state_requires_trust(self) -> None:
|
|
1590
|
+
args = duet.argparse.Namespace(
|
|
1591
|
+
verify_cmd=None,
|
|
1592
|
+
trust_state=False,
|
|
1593
|
+
dry_run=False,
|
|
1594
|
+
sandbox=None,
|
|
1595
|
+
permission_mode=None,
|
|
1596
|
+
add_dirs=None,
|
|
1597
|
+
)
|
|
1598
|
+
with self.assertRaises(SystemExit), \
|
|
1599
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
1600
|
+
duet._guard_continue_state_replay(
|
|
1601
|
+
{}, [], args, self.parser,
|
|
1602
|
+
sandbox="danger-full-access",
|
|
1603
|
+
permission_mode="bypassPermissions",
|
|
1604
|
+
add_dirs=["/"],
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
def test_safe_or_explicit_authority_values_do_not_require_trust(self) -> None:
|
|
1608
|
+
safe_args = duet.argparse.Namespace(
|
|
1609
|
+
verify_cmd=None,
|
|
1610
|
+
trust_state=False,
|
|
1611
|
+
dry_run=False,
|
|
1612
|
+
sandbox=None,
|
|
1613
|
+
permission_mode=None,
|
|
1614
|
+
add_dirs=None,
|
|
1615
|
+
)
|
|
1616
|
+
duet._guard_continue_state_replay(
|
|
1617
|
+
{}, [], safe_args, self.parser,
|
|
1618
|
+
sandbox="read-only",
|
|
1619
|
+
permission_mode="plan",
|
|
1620
|
+
add_dirs=[],
|
|
1621
|
+
)
|
|
1622
|
+
override_args = duet.argparse.Namespace(
|
|
1623
|
+
verify_cmd=None,
|
|
1624
|
+
trust_state=False,
|
|
1625
|
+
dry_run=False,
|
|
1626
|
+
sandbox="workspace-write",
|
|
1627
|
+
permission_mode="acceptEdits",
|
|
1628
|
+
add_dirs=["fresh"],
|
|
1629
|
+
)
|
|
1630
|
+
duet._guard_continue_state_replay(
|
|
1631
|
+
{}, [], override_args, self.parser,
|
|
1632
|
+
sandbox="workspace-write",
|
|
1633
|
+
permission_mode="acceptEdits",
|
|
1634
|
+
add_dirs=["fresh"],
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
# ---------- status helpers ----------
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
class TestStatusHelpers(unittest.TestCase):
|
|
1642
|
+
def test_coerce_pid_accepts_positive_integers_only(self) -> None:
|
|
1643
|
+
self.assertEqual(duet._coerce_pid(42), 42)
|
|
1644
|
+
self.assertEqual(duet._coerce_pid("42"), 42)
|
|
1645
|
+
self.assertEqual(duet._coerce_pid(42.0), 42)
|
|
1646
|
+
for value in (None, "bad", True, False, 0, -1, 1.5):
|
|
1647
|
+
with self.subTest(value=value):
|
|
1648
|
+
self.assertIsNone(duet._coerce_pid(value))
|
|
1649
|
+
|
|
1650
|
+
def test_status_safe_reports_traceback_and_returns_three(self) -> None:
|
|
1651
|
+
stderr = io.StringIO()
|
|
1652
|
+
with mock.patch.object(
|
|
1653
|
+
duet, "print_run_status", side_effect=RuntimeError("boom")), \
|
|
1654
|
+
contextlib.redirect_stderr(stderr):
|
|
1655
|
+
rc = duet.print_run_status_safe("run")
|
|
1656
|
+
|
|
1657
|
+
self.assertEqual(rc, 3)
|
|
1658
|
+
self.assertIn("status failed (RuntimeError): boom", stderr.getvalue())
|
|
1659
|
+
self.assertIn("Traceback (most recent call last)", stderr.getvalue())
|
|
1660
|
+
|
|
1661
|
+
|
|
1473
1662
|
# ---------- normalize_verify_cmd ----------
|
|
1474
1663
|
|
|
1475
1664
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|