duet-cli 0.2.17__tar.gz → 0.2.18__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.17/duet_cli.egg-info → duet_cli-0.2.18}/PKG-INFO +1 -1
- {duet_cli-0.2.17 → duet_cli-0.2.18}/duet.py +190 -34
- {duet_cli-0.2.17 → duet_cli-0.2.18/duet_cli.egg-info}/PKG-INFO +1 -1
- duet_cli-0.2.18/tests/test_zcode_backend.py +908 -0
- duet_cli-0.2.17/tests/test_zcode_backend.py +0 -439
- {duet_cli-0.2.17 → duet_cli-0.2.18}/LICENSE +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/README.md +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/SOURCES.txt +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/dependency_links.txt +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/entry_points.txt +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/requires.txt +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/top_level.txt +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/pyproject.toml +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/setup.cfg +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_backend_metrics.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_bump_release_version.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_control_plane.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_duet.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_feedback_concurrency.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_finding_reports.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_finding_workflow.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_loop_run_info.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_metrics_cli.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_metrics_collection.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_metrics_lifecycle.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_metrics_reporting.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_no_subagents.py +0 -0
- {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_stop_control_plane.py +0 -0
|
@@ -72,7 +72,7 @@ from typing import Callable, Iterable, Optional
|
|
|
72
72
|
|
|
73
73
|
# ---------- defaults ----------
|
|
74
74
|
|
|
75
|
-
__version__ = "0.2.
|
|
75
|
+
__version__ = "0.2.18"
|
|
76
76
|
|
|
77
77
|
DEFAULT_SENTINEL = "<<<LGTM>>>"
|
|
78
78
|
DEFAULT_SANDBOX = "workspace-write"
|
|
@@ -2495,6 +2495,25 @@ _ZCODE_REVIEW_DENIED_TOOLS = CLAUDE_DENIED_DELEGATION_TOOLS + (
|
|
|
2495
2495
|
)
|
|
2496
2496
|
|
|
2497
2497
|
|
|
2498
|
+
def _zcode_missing_catalog_error(cli: str) -> ValueError:
|
|
2499
|
+
example = " ZCODE_BUILTIN_PROVIDER_CONFIG_FILE=/path/to/that/installation/zcode-builtin.json duet ..."
|
|
2500
|
+
head = "Cannot locate the PATH ZCode CLI's catalog"
|
|
2501
|
+
if os.path.islink(cli):
|
|
2502
|
+
lines = [f"{head}: no catalog is in a known place for its entry file {pathlib.Path(cli).resolve()}.",
|
|
2503
|
+
"Set the catalog on the Duet command only, to that installation's JSON catalog:", example]
|
|
2504
|
+
else:
|
|
2505
|
+
lines = [f"{head}: {cli} is not a link to a ZCode entry file, for example because it is a wrapper script.",
|
|
2506
|
+
f"If {cli} runs one ZCode installation, replace it with a symbolic link to that installation's entry file.",
|
|
2507
|
+
"If it selects between installations or is a self-contained executable, set the catalog on the "
|
|
2508
|
+
"Duet command only, for the installation that runs headless calls:", example]
|
|
2509
|
+
desktop = _ZCODE_DESKTOP_RESOURCES / "config" / "provider" / "zcode-builtin.json"
|
|
2510
|
+
if sys.platform == "darwin" and desktop.is_file():
|
|
2511
|
+
lines.append(f"If that installation is the macOS desktop app, its catalog is {desktop}.")
|
|
2512
|
+
lines.append("Do not export the variable in a shell where a wrapper selects between installations. "
|
|
2513
|
+
"Duet never uses another installation's catalog. See docs/ZCODE.md#desktop-cli-in-a-terminal.")
|
|
2514
|
+
return ValueError("\n".join(lines))
|
|
2515
|
+
|
|
2516
|
+
|
|
2498
2517
|
def _zcode_command() -> tuple[list[str], dict[str, str]]:
|
|
2499
2518
|
"""Use the official CLI, or the installed macOS app's headless runtime."""
|
|
2500
2519
|
cli = shutil.which("zcode")
|
|
@@ -2502,13 +2521,16 @@ def _zcode_command() -> tuple[list[str], dict[str, str]]:
|
|
|
2502
2521
|
key = "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"
|
|
2503
2522
|
source = os.environ.get(key, "").strip()
|
|
2504
2523
|
if not source:
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2524
|
+
entry = pathlib.Path(cli).resolve()
|
|
2525
|
+
directory = entry.parent
|
|
2526
|
+
candidates = [directory / "provider/zcode-builtin.json"]
|
|
2527
|
+
if directory.name == "glm" and entry.name == "zcode.cjs":
|
|
2528
|
+
# Only the desktop bundle layout (resources/glm/zcode.cjs beside resources/config) may use a sibling catalog.
|
|
2529
|
+
candidates.append(directory.parent / "config/provider/zcode-builtin.json")
|
|
2530
|
+
candidates.append((directory / "../../../../../config/provider/zcode-builtin.json").resolve())
|
|
2508
2531
|
source = next((str(path) for path in candidates if path.is_file()), "")
|
|
2509
2532
|
if not source:
|
|
2510
|
-
raise
|
|
2511
|
-
"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE to that installation's JSON catalog")
|
|
2533
|
+
raise _zcode_missing_catalog_error(cli)
|
|
2512
2534
|
return [cli], {key: source}
|
|
2513
2535
|
entry = _ZCODE_DESKTOP_RESOURCES / "glm" / "zcode.cjs"
|
|
2514
2536
|
builtin = _ZCODE_DESKTOP_RESOURCES / "config" / "provider" / "zcode-builtin.json"
|
|
@@ -2560,6 +2582,21 @@ def _zcode_resolved_provider(config: dict, provider: dict) -> dict:
|
|
|
2560
2582
|
return resolved
|
|
2561
2583
|
|
|
2562
2584
|
|
|
2585
|
+
def _zcode_reasoning_error(selection: dict, requested: str, levels: list[str]) -> ValueError:
|
|
2586
|
+
head = ("Requested ZCode reasoning level is absent from its native model catalog: "
|
|
2587
|
+
f"{requested} for {selection['providerId']}/{selection['modelId']}.")
|
|
2588
|
+
if levels:
|
|
2589
|
+
return ValueError(
|
|
2590
|
+
f"{head} Supported levels: {', '.join(levels)}.\n"
|
|
2591
|
+
"A run-level --reasoning applies to both agents. To give this agent a supported level, "
|
|
2592
|
+
"set its reasoning_effort in a --config file. See docs/ZCODE.md#native-review-peer.")
|
|
2593
|
+
return ValueError(
|
|
2594
|
+
f"{head} The catalog lists no reasoning levels for this model.\n"
|
|
2595
|
+
"Do not set reasoning for this agent. A run-level --reasoning applies to both agents, "
|
|
2596
|
+
"so set reasoning_effort only for the other agent in a --config file. "
|
|
2597
|
+
"See docs/ZCODE.md#native-review-peer.")
|
|
2598
|
+
|
|
2599
|
+
|
|
2563
2600
|
def _zcode_catalog(selection: dict, launch_env: dict[str, str]) -> dict:
|
|
2564
2601
|
source = launch_env.get("ZCODE_BUILTIN_PROVIDER_CONFIG_FILE") or os.environ.get(
|
|
2565
2602
|
"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE")
|
|
@@ -2579,7 +2616,7 @@ def _zcode_catalog(selection: dict, launch_env: dict[str, str]) -> dict:
|
|
|
2579
2616
|
levels = _zcode_catalog_reasoning(config["modelConfigRules"], provider, selection["modelId"])
|
|
2580
2617
|
requested = selection.get("options", {}).get("reasoningLevel")
|
|
2581
2618
|
if requested is not None and requested not in levels:
|
|
2582
|
-
raise
|
|
2619
|
+
raise _zcode_reasoning_error(selection, requested, levels)
|
|
2583
2620
|
return data
|
|
2584
2621
|
|
|
2585
2622
|
|
|
@@ -2638,22 +2675,53 @@ def _zcode_catalog_reasoning(rules: dict, provider: dict, model: str) -> list[st
|
|
|
2638
2675
|
return levels
|
|
2639
2676
|
|
|
2640
2677
|
|
|
2641
|
-
def
|
|
2678
|
+
def _zcode_project_directories(cwd: pathlib.Path) -> list[pathlib.Path]:
|
|
2642
2679
|
directory = pathlib.Path(os.path.abspath(cwd))
|
|
2643
2680
|
ancestors = [directory, *directory.parents]
|
|
2644
|
-
directories = [directory]
|
|
2645
2681
|
for index, parent in enumerate(ancestors):
|
|
2646
2682
|
if (parent / ".git").exists():
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
return [pathlib.Path.home() / ".zcode/cli/config.json", *[
|
|
2650
|
-
path for folder in directories for path in (folder / "zcode.json", folder / ".zcode/config.json")]]
|
|
2683
|
+
return list(reversed(ancestors[:index + 1]))
|
|
2684
|
+
return [directory]
|
|
2651
2685
|
|
|
2652
2686
|
|
|
2653
|
-
def
|
|
2687
|
+
def _zcode_permission_paths(cwd: pathlib.Path) -> list[pathlib.Path]:
|
|
2688
|
+
return [pathlib.Path.home() / ".zcode/cli/config.json", *[
|
|
2689
|
+
path for folder in _zcode_project_directories(cwd)
|
|
2690
|
+
for path in (folder / "zcode.json", folder / ".zcode/config.json")]]
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
def _zcode_mcp_gate_error(fingerprints: dict, features_path: Optional[pathlib.Path],
|
|
2694
|
+
cwd: pathlib.Path, origin: Optional[pathlib.Path]) -> ValueError:
|
|
2695
|
+
paths = list(fingerprints)
|
|
2696
|
+
project_features = features_path if features_path in paths[1:] else None
|
|
2697
|
+
start = paths.index(project_features) + 1 if project_features else 1
|
|
2698
|
+
target = next((path for path in reversed(paths[start:]) if fingerprints[path] is None), None)
|
|
2699
|
+
document = '{"features": {"mcp": false}}'
|
|
2700
|
+
verb, path = ("create", target) if target else ("change", project_features or paths[-1])
|
|
2701
|
+
if origin:
|
|
2702
|
+
verb += " and commit"
|
|
2703
|
+
path = origin / path.relative_to(pathlib.Path(os.path.abspath(cwd)))
|
|
2704
|
+
action = f"{verb} {path} {'with' if target else 'to'} exactly {document}"
|
|
2705
|
+
lost = "" if target else f" Other settings in {path} are lost, so move any that you need to another file first."
|
|
2706
|
+
folders = ", ".join(str(path.parent) for path in paths[1::2])
|
|
2707
|
+
scope = " of the worktree" if origin else ""
|
|
2708
|
+
return ValueError(
|
|
2709
|
+
f"ZCode review requires native features.mcp=false: {action}\n"
|
|
2710
|
+
"The file must contain only this document. Duet does not accept MCP=false from a file "
|
|
2711
|
+
f"that has other settings.{lost}\n"
|
|
2712
|
+
f"Duet reads {paths[0]}, then zcode.json and .zcode/config.json in each of these folders{scope}: "
|
|
2713
|
+
f"{folders}. The last file that sets \"features\" decides.\n"
|
|
2714
|
+
"Plan mode permits some MCP writes, so Duet does not start a ZCode review without this setting.\n"
|
|
2715
|
+
"A file that is not committed does not reach a new Duet worktree. "
|
|
2716
|
+
"See docs/ZCODE.md#native-mcp-prerequisite.")
|
|
2717
|
+
|
|
2718
|
+
|
|
2719
|
+
def _zcode_permission_denials(cwd: pathlib.Path,
|
|
2720
|
+
origin: Optional[pathlib.Path] = None) -> tuple[list[str], dict]:
|
|
2654
2721
|
denied = list(_ZCODE_REVIEW_DENIED_TOOLS)
|
|
2655
2722
|
fingerprints = {}
|
|
2656
2723
|
mcp_disabled = False
|
|
2724
|
+
features_path = None
|
|
2657
2725
|
for path in _zcode_permission_paths(cwd):
|
|
2658
2726
|
try:
|
|
2659
2727
|
raw = path.read_bytes()
|
|
@@ -2674,6 +2742,7 @@ def _zcode_permission_denials(cwd: pathlib.Path) -> tuple[list[str], dict]:
|
|
|
2674
2742
|
if "features" in data:
|
|
2675
2743
|
if not isinstance(data["features"], dict):
|
|
2676
2744
|
raise ValueError()
|
|
2745
|
+
features_path = path
|
|
2677
2746
|
# Native ignores a whole invalid document. Certify only the
|
|
2678
2747
|
# minimal schema we validate completely, not a richer file
|
|
2679
2748
|
# whose unrelated fields could cause MCP=false to be skipped.
|
|
@@ -2690,7 +2759,7 @@ def _zcode_permission_denials(cwd: pathlib.Path) -> tuple[list[str], dict]:
|
|
|
2690
2759
|
except (ValueError, AttributeError, TypeError):
|
|
2691
2760
|
raise ValueError("Unsupported native ZCode permission configuration; refusing to replace it") from None
|
|
2692
2761
|
if not mcp_disabled:
|
|
2693
|
-
raise
|
|
2762
|
+
raise _zcode_mcp_gate_error(fingerprints, features_path, cwd, origin)
|
|
2694
2763
|
return list(dict.fromkeys(denied)), fingerprints
|
|
2695
2764
|
|
|
2696
2765
|
|
|
@@ -2740,6 +2809,33 @@ def _zcode_peer_profile(selection: dict, launch_env: dict[str, str]):
|
|
|
2740
2809
|
"ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE": ""}
|
|
2741
2810
|
|
|
2742
2811
|
|
|
2812
|
+
_ZCODE_FAILURE_HINTS = (
|
|
2813
|
+
("project_token_login_failed",
|
|
2814
|
+
"[duet] hint: ZCode could not get a project token from the account service. "
|
|
2815
|
+
"Make sure that the network is available. Then sign in again with the login "
|
|
2816
|
+
"command in docs/ZCODE.md#account-initialization."),
|
|
2817
|
+
("project_token_login_required",
|
|
2818
|
+
"[duet] hint: ZCode has no stored login token or account identity. "
|
|
2819
|
+
"Sign in with the login command in docs/ZCODE.md#account-initialization."),
|
|
2820
|
+
("Select a model before",
|
|
2821
|
+
"[duet] hint: ZCode has no usable model. Sign in again with the login command "
|
|
2822
|
+
"in docs/ZCODE.md#account-initialization, and make sure that the provider ID "
|
|
2823
|
+
"belongs to the connected account."),
|
|
2824
|
+
)
|
|
2825
|
+
|
|
2826
|
+
|
|
2827
|
+
def _zcode_failure_hint(rc: int, out: str, err: str) -> str:
|
|
2828
|
+
if rc == 124:
|
|
2829
|
+
return ""
|
|
2830
|
+
try:
|
|
2831
|
+
result = json.loads(out)
|
|
2832
|
+
except (ValueError, TypeError):
|
|
2833
|
+
result = None
|
|
2834
|
+
text = str(result.get("error") or "") if isinstance(result, dict) else ""
|
|
2835
|
+
text += "\n" + err
|
|
2836
|
+
return next(("\n" + line for marker, line in _ZCODE_FAILURE_HINTS if marker in text), "")
|
|
2837
|
+
|
|
2838
|
+
|
|
2743
2839
|
def _parse_zcode_reply(stdout: str) -> tuple[str, str]:
|
|
2744
2840
|
try:
|
|
2745
2841
|
result = json.loads(stdout)
|
|
@@ -2763,7 +2859,8 @@ def call_zcode(agent: Agent, system_prompt: str, message: str,
|
|
|
2763
2859
|
cwd: pathlib.Path, timeout: int, dry: bool,
|
|
2764
2860
|
reasoning: Optional[str] = None,
|
|
2765
2861
|
stderr_log_path: Optional[pathlib.Path] = None,
|
|
2766
|
-
pid_file_path: Optional[pathlib.Path] = None
|
|
2862
|
+
pid_file_path: Optional[pathlib.Path] = None,
|
|
2863
|
+
origin: Optional[pathlib.Path] = None) -> tuple[str, Optional[str]]:
|
|
2767
2864
|
selection = _zcode_requested_selection(agent, reasoning)
|
|
2768
2865
|
pin = {"model": agent.model, "reasoning": reasoning}
|
|
2769
2866
|
if dry:
|
|
@@ -2775,7 +2872,7 @@ def call_zcode(agent: Agent, system_prompt: str, message: str,
|
|
|
2775
2872
|
try:
|
|
2776
2873
|
command, launch_env = _zcode_command()
|
|
2777
2874
|
native_cwd = pathlib.Path(agent.cwd_override or cwd)
|
|
2778
|
-
denied, permission_inputs = _zcode_permission_denials(native_cwd)
|
|
2875
|
+
denied, permission_inputs = _zcode_permission_denials(native_cwd, origin)
|
|
2779
2876
|
command += ["--cwd", str(native_cwd), "--mode", "plan",
|
|
2780
2877
|
"--workflow-mode", "disabled", "--json", "--no-color",
|
|
2781
2878
|
"--disallowed-tools", ",".join(denied)]
|
|
@@ -2787,10 +2884,14 @@ def call_zcode(agent: Agent, system_prompt: str, message: str,
|
|
|
2787
2884
|
rc, out, err = _run(command, cwd=agent.cwd_override or cwd, stdin=None,
|
|
2788
2885
|
timeout=timeout, stderr_log_path=stderr_log_path,
|
|
2789
2886
|
pid_file_path=pid_file_path, env_overrides=env)
|
|
2887
|
+
hint = _zcode_failure_hint(rc, out, err)
|
|
2790
2888
|
if rc != 0:
|
|
2791
2889
|
reason = FINISHED_TIMEOUT if rc == 124 else FINISHED_AGENT_ERROR
|
|
2792
|
-
raise AgentRunError(reason, _agent_exit_error("zcode", rc, out, err))
|
|
2793
|
-
|
|
2890
|
+
raise AgentRunError(reason, _agent_exit_error("zcode", rc, out, err, hint))
|
|
2891
|
+
try:
|
|
2892
|
+
response, session = _parse_zcode_reply(out)
|
|
2893
|
+
except ValueError as exc:
|
|
2894
|
+
raise ValueError(f"{exc}{hint}") from exc
|
|
2794
2895
|
if agent.session_id and session != agent.session_id:
|
|
2795
2896
|
raise ValueError("ZCode changed its pinned sessionId; refusing an unrelated conversation")
|
|
2796
2897
|
except AgentRunError:
|
|
@@ -2802,6 +2903,35 @@ def call_zcode(agent: Agent, system_prompt: str, message: str,
|
|
|
2802
2903
|
return response, session
|
|
2803
2904
|
|
|
2804
2905
|
|
|
2906
|
+
def _zcode_worktree_origin(agent: Agent, cfg: DuetConfig) -> Optional[pathlib.Path]:
|
|
2907
|
+
created = cfg.worktree and not cfg.worktree_path
|
|
2908
|
+
return _zcode_project_directories(cfg.cwd)[0] if created and agent.cwd_override else None
|
|
2909
|
+
|
|
2910
|
+
|
|
2911
|
+
def _zcode_launch_check(agent: Agent, cfg: DuetConfig, native_cwd: Optional[pathlib.Path]) -> None:
|
|
2912
|
+
selection = _zcode_requested_selection(agent, effective_reasoning(agent, cfg.reasoning))
|
|
2913
|
+
_, launch_env = _zcode_command()
|
|
2914
|
+
if native_cwd:
|
|
2915
|
+
_zcode_permission_denials(native_cwd, _zcode_worktree_origin(agent, cfg))
|
|
2916
|
+
with _zcode_peer_profile(selection, launch_env):
|
|
2917
|
+
pass
|
|
2918
|
+
|
|
2919
|
+
|
|
2920
|
+
def _zcode_preflight(cfg: DuetConfig, *, worktree_ready: bool) -> Optional[str]:
|
|
2921
|
+
if cfg.dry_run:
|
|
2922
|
+
return None
|
|
2923
|
+
deferred = _worktree_agent_index(cfg) if cfg.worktree or cfg.worktree_path else -1
|
|
2924
|
+
for index, agent in enumerate(cfg.agents):
|
|
2925
|
+
if agent.backend != "zcode" or (worktree_ready and index != deferred):
|
|
2926
|
+
continue
|
|
2927
|
+
native_cwd = None if index == deferred and not worktree_ready else pathlib.Path(agent.cwd_override or cfg.cwd)
|
|
2928
|
+
try:
|
|
2929
|
+
_zcode_launch_check(agent, cfg, native_cwd)
|
|
2930
|
+
except Exception as exc:
|
|
2931
|
+
return f"ZCode launch check failed for agent {agent.name}: {exc}"
|
|
2932
|
+
return None
|
|
2933
|
+
|
|
2934
|
+
|
|
2805
2935
|
def _call_zcode_backend(agent: Agent, system_prompt: str, message: str,
|
|
2806
2936
|
cfg: DuetConfig, first_turn: bool,
|
|
2807
2937
|
stderr_log_path: Optional[pathlib.Path],
|
|
@@ -2809,7 +2939,8 @@ def _call_zcode_backend(agent: Agent, system_prompt: str, message: str,
|
|
|
2809
2939
|
reasoning: Optional[str]) -> tuple[str, Optional[str]]:
|
|
2810
2940
|
return call_zcode(agent, system_prompt, message, cfg.cwd,
|
|
2811
2941
|
cfg.per_turn_timeout, cfg.dry_run, reasoning,
|
|
2812
|
-
stderr_log_path, pid_file_path
|
|
2942
|
+
stderr_log_path, pid_file_path,
|
|
2943
|
+
_zcode_worktree_origin(agent, cfg))
|
|
2813
2944
|
|
|
2814
2945
|
|
|
2815
2946
|
BACKENDS = {
|
|
@@ -3239,6 +3370,10 @@ def _worktree_setup_failed(cfg: DuetConfig, message: str) -> None:
|
|
|
3239
3370
|
file=sys.stderr)
|
|
3240
3371
|
|
|
3241
3372
|
|
|
3373
|
+
def _worktree_agent_index(cfg: DuetConfig) -> int:
|
|
3374
|
+
return {"lead": 0, "partner": 1}.get(cfg.worktree_for, 1)
|
|
3375
|
+
|
|
3376
|
+
|
|
3242
3377
|
def _setup_run_worktree(
|
|
3243
3378
|
cfg: DuetConfig, run_id: str, run_dir: pathlib.Path,
|
|
3244
3379
|
) -> tuple[Optional[pathlib.Path], Optional[str]]:
|
|
@@ -3251,7 +3386,7 @@ def _setup_run_worktree(
|
|
|
3251
3386
|
with a durable setup failure. A failed *create* still reports the intended
|
|
3252
3387
|
branch name, matching the pre-extraction behavior.
|
|
3253
3388
|
"""
|
|
3254
|
-
wt_idx =
|
|
3389
|
+
wt_idx = _worktree_agent_index(cfg)
|
|
3255
3390
|
if cfg.worktree_path:
|
|
3256
3391
|
existing = pathlib.Path(cfg.worktree_path).expanduser().resolve()
|
|
3257
3392
|
if not existing.is_dir():
|
|
@@ -4245,6 +4380,21 @@ def _print_run_banner(cfg: DuetConfig, context: _RunContext) -> None:
|
|
|
4245
4380
|
f"{cfg.agents[0].session_id}")
|
|
4246
4381
|
|
|
4247
4382
|
|
|
4383
|
+
def _zcode_preflight_failure(cfg: DuetConfig, context: _RunContext, *,
|
|
4384
|
+
worktree_ready: bool) -> Optional[dict]:
|
|
4385
|
+
message = _zcode_preflight(cfg, worktree_ready=worktree_ready)
|
|
4386
|
+
if message is None:
|
|
4387
|
+
return None
|
|
4388
|
+
state = _launch_failure_state(
|
|
4389
|
+
cfg, reason=FINISHED_SETUP_ERROR, message=message,
|
|
4390
|
+
history=context.history, transcript_path=context.transcript_path,
|
|
4391
|
+
recap_path=context.recap_path, state_path=context.state_path,
|
|
4392
|
+
wt_path=context.wt_path, wt_branch=context.wt_branch,
|
|
4393
|
+
)
|
|
4394
|
+
_print_worktree_notice(cfg, context.wt_path, context.wt_branch)
|
|
4395
|
+
return state
|
|
4396
|
+
|
|
4397
|
+
|
|
4248
4398
|
def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
|
|
4249
4399
|
"""Allocate, publish launch metadata, isolate, and run deferred kickoff."""
|
|
4250
4400
|
_validate_run_info_target(cfg)
|
|
@@ -4305,6 +4455,9 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
|
|
|
4305
4455
|
return context, state
|
|
4306
4456
|
|
|
4307
4457
|
_print_run_banner(cfg, context)
|
|
4458
|
+
failure = _zcode_preflight_failure(cfg, context, worktree_ready=False)
|
|
4459
|
+
if failure is not None:
|
|
4460
|
+
return context, failure
|
|
4308
4461
|
_probe_run_metrics(cfg)
|
|
4309
4462
|
failure = _run_kickoff_command(
|
|
4310
4463
|
cfg, run_dir=run_dir, transcript_path=transcript_path,
|
|
@@ -4341,6 +4494,9 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
|
|
|
4341
4494
|
)
|
|
4342
4495
|
return context, state
|
|
4343
4496
|
|
|
4497
|
+
failure = _zcode_preflight_failure(cfg, context, worktree_ready=True)
|
|
4498
|
+
if failure is not None:
|
|
4499
|
+
return context, failure
|
|
4344
4500
|
setup_state = _build_run_state(
|
|
4345
4501
|
cfg, turns_used=0, history=history, finished_reason=None,
|
|
4346
4502
|
transcript_path=transcript_path, recap_path=recap_path,
|
|
@@ -4351,6 +4507,16 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
|
|
|
4351
4507
|
return context, None
|
|
4352
4508
|
|
|
4353
4509
|
|
|
4510
|
+
def _print_worktree_notice(cfg: DuetConfig, wt_path: Optional[pathlib.Path],
|
|
4511
|
+
wt_branch: Optional[str]) -> None:
|
|
4512
|
+
if wt_path:
|
|
4513
|
+
print(f"[duet] worktree left intact at {wt_path} (branch {wt_branch}).\n"
|
|
4514
|
+
f" merge: git -C {cfg.cwd} merge {wt_branch}\n"
|
|
4515
|
+
f" review: git -C {wt_path} diff HEAD\n"
|
|
4516
|
+
f" drop: git -C {cfg.cwd} worktree remove {wt_path} && "
|
|
4517
|
+
f"git -C {cfg.cwd} branch -D {wt_branch}")
|
|
4518
|
+
|
|
4519
|
+
|
|
4354
4520
|
def run_duet(cfg: DuetConfig) -> dict:
|
|
4355
4521
|
global RECAP_MODE
|
|
4356
4522
|
RECAP_MODE = cfg.recap
|
|
@@ -4470,12 +4636,7 @@ def run_duet(cfg: DuetConfig) -> dict:
|
|
|
4470
4636
|
print(f"[duet] recap: {recap_path}")
|
|
4471
4637
|
print(f"[duet] resumable session ids — "
|
|
4472
4638
|
+ ", ".join(f"{a.name}={a.session_id}" for a in cfg.agents if a.session_id))
|
|
4473
|
-
|
|
4474
|
-
print(f"[duet] worktree left intact at {wt_path} (branch {wt_branch}).\n"
|
|
4475
|
-
f" merge: git -C {cfg.cwd} merge {wt_branch}\n"
|
|
4476
|
-
f" review: git -C {wt_path} diff HEAD\n"
|
|
4477
|
-
f" drop: git -C {cfg.cwd} worktree remove {wt_path} && "
|
|
4478
|
-
f"git -C {cfg.cwd} branch -D {wt_branch}")
|
|
4639
|
+
_print_worktree_notice(cfg, wt_path, wt_branch)
|
|
4479
4640
|
return state
|
|
4480
4641
|
|
|
4481
4642
|
|
|
@@ -7982,14 +8143,9 @@ def _validate_ready_config(cfg: DuetConfig,
|
|
|
7982
8143
|
def _warn_missing_backends(cfg: DuetConfig) -> None:
|
|
7983
8144
|
if cfg.dry_run:
|
|
7984
8145
|
return
|
|
7985
|
-
|
|
8146
|
+
# The ZCode launch check reports the real ZCode error.
|
|
8147
|
+
for backend in {a.backend for a in cfg.agents} - {"zcode"}:
|
|
7986
8148
|
binary = backend_adapter(backend).binary
|
|
7987
|
-
if backend == "zcode":
|
|
7988
|
-
try:
|
|
7989
|
-
_zcode_command()
|
|
7990
|
-
continue
|
|
7991
|
-
except ValueError:
|
|
7992
|
-
pass
|
|
7993
8149
|
if shutil.which(binary) is None:
|
|
7994
8150
|
print(
|
|
7995
8151
|
f"[duet] WARNING: '{binary}' not on PATH — this run will fail. "
|