forgexa-cli 1.62.5__tar.gz → 1.63.1__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.
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/PKG-INFO +1 -1
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/agent_core.py +19 -12
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/daemon.py +199 -79
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/pyproject.toml +1 -1
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/README.md +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/setup.cfg +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.63.1"
|
|
@@ -1309,12 +1309,14 @@ def _kill_proc(proc: asyncio.subprocess.Process) -> None:
|
|
|
1309
1309
|
(npm, yarn, ssh, git, etc.) spawned by the agent stay alive, keep pipes
|
|
1310
1310
|
open, and exhaust system resources. ``os.killpg`` sends SIGKILL to the
|
|
1311
1311
|
whole process group, reliably cleaning up all descendants.
|
|
1312
|
+
Owned agents start a new session, so their PID remains the process-group
|
|
1313
|
+
ID even after the leader exits.
|
|
1312
1314
|
"""
|
|
1313
1315
|
try:
|
|
1314
1316
|
if sys.platform != "win32":
|
|
1315
1317
|
import signal as _signal
|
|
1316
1318
|
try:
|
|
1317
|
-
os.killpg(
|
|
1319
|
+
os.killpg(proc.pid, _signal.SIGKILL)
|
|
1318
1320
|
except (ProcessLookupError, PermissionError, OSError):
|
|
1319
1321
|
pass
|
|
1320
1322
|
else:
|
|
@@ -1322,6 +1324,7 @@ def _kill_proc(proc: asyncio.subprocess.Process) -> None:
|
|
|
1322
1324
|
_subprocess.run(
|
|
1323
1325
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
1324
1326
|
capture_output=True,
|
|
1327
|
+
timeout=5,
|
|
1325
1328
|
**_agent_process_group_kwargs(),
|
|
1326
1329
|
)
|
|
1327
1330
|
except Exception:
|
|
@@ -2462,7 +2465,7 @@ def deepseek_harness_build_info(binary: str) -> dict:
|
|
|
2462
2465
|
if result.returncode or not re.fullmatch(r"\d+\.\d+\.\d+", node_version) or parsed is None or not ((parsed[0] == 22 and parsed >= (22, 19, 0)) or parsed[0] >= 24):
|
|
2463
2466
|
raise RuntimeError("DeepSeek Harness requires Node 22.19+ or Node 24+")
|
|
2464
2467
|
digest = hashlib.sha256()
|
|
2465
|
-
digest.update(f"{sys.platform}:{platform.machine()}:{platform.release()}:{node_version}:harness-policy-
|
|
2468
|
+
digest.update(f"{sys.platform}:{platform.machine()}:{platform.release()}:{node_version}:harness-policy-4".encode())
|
|
2466
2469
|
extensions = [manifest.parent for manifest in (roots[0] / "node_modules" / "@deepseek-ai").glob("dsh*/package.json")]
|
|
2467
2470
|
for root in sorted(set([*roots, *extensions])):
|
|
2468
2471
|
files = [root / "package.json"] + sorted((root / "lib").rglob("*.js"))
|
|
@@ -2592,10 +2595,15 @@ def deepseek_harness_overlay(*, cwd: Path, credentials_path: Path, provider: str
|
|
|
2592
2595
|
"watch": False,
|
|
2593
2596
|
}},
|
|
2594
2597
|
{"id": "tool-skill", "disabled": False},
|
|
2598
|
+
{"id": "tool-subagent", "disabled": False, "config": {
|
|
2599
|
+
"provider": "spawn", "enableRunInBackground": False,
|
|
2600
|
+
"backgroundMode": "one-shot", "maxDepth": 1,
|
|
2601
|
+
"modelSelectionSettings": False,
|
|
2602
|
+
}},
|
|
2595
2603
|
]
|
|
2596
2604
|
disabled = (
|
|
2597
2605
|
"session-telemetry-otel", "hmr", "plugin-manager", "tool-plugin-manager",
|
|
2598
|
-
"llm-pi-ai", "tool-subagent
|
|
2606
|
+
"llm-pi-ai", "tool-subagent-fork",
|
|
2599
2607
|
"tool-subagent-control", "tool-subagent-list-agents", "tool-workflow",
|
|
2600
2608
|
"goal-round-driver", "command-goal", "tool-goal", "command-feedback", "tool-web",
|
|
2601
2609
|
)
|
|
@@ -2862,7 +2870,7 @@ def mimo_base_env(env: dict[str, str]) -> dict[str, str]:
|
|
|
2862
2870
|
"git * reset --hard*": "deny",
|
|
2863
2871
|
},
|
|
2864
2872
|
"external_directory": "deny",
|
|
2865
|
-
"task": "
|
|
2873
|
+
"task": "allow",
|
|
2866
2874
|
"skill": {"*": "allow", "compose:*": "deny"},
|
|
2867
2875
|
"webfetch": "deny",
|
|
2868
2876
|
"websearch": "deny",
|
|
@@ -3168,9 +3176,9 @@ def zcode_build_verification_issue(binary: str | Path, env: dict[str, str] | Non
|
|
|
3168
3176
|
return None
|
|
3169
3177
|
|
|
3170
3178
|
|
|
3171
|
-
ZCODE_ALLOWED_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Skill")
|
|
3179
|
+
ZCODE_ALLOWED_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Skill", "Agent", "Task")
|
|
3172
3180
|
ZCODE_DENIED_TOOLS = (
|
|
3173
|
-
"Bash", "MCP", "Plugin", "
|
|
3181
|
+
"Bash", "MCP", "Plugin", "WebFetch", "WebSearch",
|
|
3174
3182
|
)
|
|
3175
3183
|
|
|
3176
3184
|
|
|
@@ -3247,10 +3255,8 @@ def agent_execution_verification_issue(agent_id: str, binary: str) -> str | None
|
|
|
3247
3255
|
return None
|
|
3248
3256
|
|
|
3249
3257
|
|
|
3250
|
-
_FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill")
|
|
3258
|
+
_FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill", "Agent", "Task")
|
|
3251
3259
|
_FORGEXA_CODING_AGENT_DENIED_TOOLS = (
|
|
3252
|
-
"Agent",
|
|
3253
|
-
"Task",
|
|
3254
3260
|
"SlashCommand",
|
|
3255
3261
|
"WebFetch",
|
|
3256
3262
|
"WebSearch",
|
|
@@ -3655,9 +3661,10 @@ def build_copilot_command(
|
|
|
3655
3661
|
|
|
3656
3662
|
``prompt_via_stdin`` omits ``-p`` so the caller pipes the prompt into
|
|
3657
3663
|
stdin instead - verified live on CLI 1.0.86, where piped stdin is a full
|
|
3658
|
-
non-interactive prompt source. The
|
|
3659
|
-
|
|
3660
|
-
|
|
3664
|
+
non-interactive prompt source. The daemon uses it for native launchers
|
|
3665
|
+
to avoid single-argument and total command-line limits; Windows batch
|
|
3666
|
+
wrappers retain task-file redirection. The host uses it when the argv
|
|
3667
|
+
form would exceed the platform command-line limit.
|
|
3661
3668
|
"""
|
|
3662
3669
|
cmd = [
|
|
3663
3670
|
binary,
|
|
@@ -114,7 +114,6 @@ _redirect_prompt_through_task_file = agent_core._redirect_prompt_through_task_fi
|
|
|
114
114
|
_agent_process_group_kwargs = agent_core._agent_process_group_kwargs
|
|
115
115
|
_parse_semver = agent_core._parse_semver
|
|
116
116
|
_check_agent_min_version = agent_core._check_agent_min_version
|
|
117
|
-
_kill_proc = agent_core._kill_proc
|
|
118
117
|
_extract_output_signals = agent_core._extract_output_signals
|
|
119
118
|
_should_scan_short_success_stdout = agent_core._should_scan_short_success_stdout
|
|
120
119
|
_copilot_completed_without_result = agent_core._copilot_completed_without_result
|
|
@@ -126,6 +125,23 @@ _parse_kimi_wire_metrics = agent_core._parse_kimi_wire_metrics
|
|
|
126
125
|
_parse_copilot_output = agent_core._parse_copilot_output
|
|
127
126
|
_extract_copilot_exit_code = agent_core._extract_copilot_exit_code
|
|
128
127
|
|
|
128
|
+
# Only the fixed, admitted entry can select managed mode, never an env flag.
|
|
129
|
+
_wsl_policy_module = sys.modules.get("app.wsl_runtime_policy") or sys.modules.get("wsl_runtime_policy")
|
|
130
|
+
_MANAGED_WSL_POLICY = getattr(_wsl_policy_module, "ACTIVE_POLICY", None)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _create_subprocess_exec(*command, **kwargs):
|
|
134
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
135
|
+
return await _MANAGED_WSL_POLICY.spawn(*command, **kwargs)
|
|
136
|
+
return await asyncio.create_subprocess_exec(*command, **kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _kill_proc(proc) -> None:
|
|
140
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
141
|
+
_MANAGED_WSL_POLICY.cancel_process(proc)
|
|
142
|
+
else:
|
|
143
|
+
agent_core._kill_proc(proc)
|
|
144
|
+
|
|
129
145
|
# ── httpx dependency — robust auto-install for standalone environments ──
|
|
130
146
|
# When running inside the backend package, httpx is a declared dependency and
|
|
131
147
|
# always available. In standalone contexts (desktop AppImage/DMG/MSI, CLI
|
|
@@ -136,10 +152,13 @@ _extract_copilot_exit_code = agent_core._extract_copilot_exit_code
|
|
|
136
152
|
# 3. Auto-install via pip --target to the cached deps dir
|
|
137
153
|
# (bypasses PEP 668 / externally-managed-environment on modern distros)
|
|
138
154
|
# 4. Friendly error with OS-specific instructions if all else fails
|
|
139
|
-
_HTTPX_DEPS_DIR =
|
|
155
|
+
_HTTPX_DEPS_DIR = (str(_MANAGED_WSL_POLICY.state / "deps") if _MANAGED_WSL_POLICY is not None
|
|
156
|
+
else os.path.join(str(Path.home()), ".forgexa", "daemon", "deps"))
|
|
140
157
|
|
|
141
158
|
|
|
142
159
|
def _cli_config_path() -> Path:
|
|
160
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
161
|
+
return _MANAGED_WSL_POLICY.state / "config.json"
|
|
143
162
|
return Path.home() / ".forgexa" / "config"
|
|
144
163
|
|
|
145
164
|
|
|
@@ -230,10 +249,14 @@ def _local_runtime_registration_path() -> Path:
|
|
|
230
249
|
nor workspace paths, so desktop clients can safely use it to avoid
|
|
231
250
|
binding a local folder to a runtime on another machine.
|
|
232
251
|
"""
|
|
252
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
253
|
+
return _MANAGED_WSL_POLICY.state / "runtime-registrations.json"
|
|
233
254
|
return Path.home() / ".forgexa" / "daemon" / "runtime-registrations.json"
|
|
234
255
|
|
|
235
256
|
|
|
236
257
|
def _desktop_daemon_restart_request_path(instance_id: str) -> Path:
|
|
258
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
259
|
+
return _MANAGED_WSL_POLICY.state / "restart-requests" / f"{instance_id}.json"
|
|
237
260
|
return (
|
|
238
261
|
Path.home()
|
|
239
262
|
/ ".forgexa"
|
|
@@ -324,6 +347,8 @@ def _clear_local_runtime_registration_snapshot(instance_id: str) -> None:
|
|
|
324
347
|
|
|
325
348
|
|
|
326
349
|
def _cli_credentials_path() -> Path:
|
|
350
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
351
|
+
return _MANAGED_WSL_POLICY.state / "credentials.json"
|
|
327
352
|
return Path.home() / ".forgexa" / "credentials.json"
|
|
328
353
|
|
|
329
354
|
|
|
@@ -374,6 +399,11 @@ def _migrate_legacy_cli_credentials(*, write_lock_held: bool = False) -> dict:
|
|
|
374
399
|
|
|
375
400
|
def _load_cli_credentials(*, write_lock_held: bool = False) -> dict:
|
|
376
401
|
"""Load issuer-scoped credentials, migrating legacy config on first use."""
|
|
402
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
403
|
+
return {_normalize_issuer_url(_MANAGED_WSL_POLICY.config.serverUrl): {
|
|
404
|
+
"runtime_credential": _MANAGED_WSL_POLICY.credential,
|
|
405
|
+
"runtime_id": _MANAGED_WSL_POLICY.runtime_id,
|
|
406
|
+
}}
|
|
377
407
|
path = _cli_credentials_path()
|
|
378
408
|
if path.exists():
|
|
379
409
|
try:
|
|
@@ -920,6 +950,9 @@ def _validate_httpx_imports() -> tuple[bool, str]:
|
|
|
920
950
|
# Actual import sequence
|
|
921
951
|
_httpx_ok, _httpx_missing = _validate_httpx_imports()
|
|
922
952
|
|
|
953
|
+
if not _httpx_ok and _MANAGED_WSL_POLICY is not None:
|
|
954
|
+
raise RuntimeError("Managed WSL runtime dependency closure is incomplete")
|
|
955
|
+
|
|
923
956
|
if not _httpx_ok:
|
|
924
957
|
# Check cached deps directory (previous auto-install)
|
|
925
958
|
if _HTTPX_DEPS_DIR not in sys.path:
|
|
@@ -997,6 +1030,8 @@ def _make_httpx_ssl_context():
|
|
|
997
1030
|
|
|
998
1031
|
# ── Settings: graceful fallback when running standalone (outside backend package) ──
|
|
999
1032
|
try:
|
|
1033
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
1034
|
+
raise ModuleNotFoundError("Managed WSL uses admitted standalone settings")
|
|
1000
1035
|
from app.config import settings
|
|
1001
1036
|
except (ImportError, ModuleNotFoundError):
|
|
1002
1037
|
# Running standalone (e.g., from desktop app bundle or forgexa-cli).
|
|
@@ -1196,7 +1231,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1196
1231
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1197
1232
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1198
1233
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1199
|
-
DAEMON_VERSION = "1.
|
|
1234
|
+
DAEMON_VERSION = "1.63.1"
|
|
1200
1235
|
|
|
1201
1236
|
|
|
1202
1237
|
def _detect_client_type() -> str:
|
|
@@ -1234,7 +1269,8 @@ _CLIENT_TYPE = _detect_client_type()
|
|
|
1234
1269
|
# so logs are visible on the terminal without duplication.
|
|
1235
1270
|
# Set DAEMON_NO_CONSOLE_LOG=1 to suppress the stream handler even on a TTY
|
|
1236
1271
|
# (used by `forgexa daemon start` foreground mode for clean UX).
|
|
1237
|
-
_log_dir =
|
|
1272
|
+
_log_dir = (_MANAGED_WSL_POLICY.state / "logs" if _MANAGED_WSL_POLICY is not None
|
|
1273
|
+
else Path.home() / ".forgexa" / "daemon")
|
|
1238
1274
|
_log_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
1239
1275
|
# Ensure directory has restricted permissions even if it already existed
|
|
1240
1276
|
_log_dir.chmod(0o700)
|
|
@@ -1512,6 +1548,8 @@ def _agent_dicts_for_wire(agents: "list[DiscoveredAgent]") -> list[dict]:
|
|
|
1512
1548
|
|
|
1513
1549
|
def _resolve_git_command() -> str | None:
|
|
1514
1550
|
"""Return Git's executable, including common Windows installer locations."""
|
|
1551
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
1552
|
+
return _MANAGED_WSL_POLICY.executable("git")
|
|
1515
1553
|
resolved = shutil.which("git")
|
|
1516
1554
|
if resolved or sys.platform != "win32":
|
|
1517
1555
|
return resolved
|
|
@@ -2478,6 +2516,8 @@ def _local_workspaces_mapping_path() -> Path:
|
|
|
2478
2516
|
|
|
2479
2517
|
|
|
2480
2518
|
def _load_local_workspaces_mapping() -> dict:
|
|
2519
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2520
|
+
return _MANAGED_WSL_POLICY.mapping()
|
|
2481
2521
|
path = _local_workspaces_mapping_path()
|
|
2482
2522
|
if not path.exists():
|
|
2483
2523
|
return {}
|
|
@@ -2519,6 +2559,8 @@ _LOCAL_BIND_SHARED_OUTPUT_MAX_LINES = 5_000
|
|
|
2519
2559
|
|
|
2520
2560
|
|
|
2521
2561
|
def _local_bind_output_log_path(task_id: str) -> Path:
|
|
2562
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2563
|
+
return _MANAGED_WSL_POLICY.output_path(task_id)
|
|
2522
2564
|
return Path.home() / ".forgexa" / "local-bind-output" / f"{task_id}.log"
|
|
2523
2565
|
|
|
2524
2566
|
|
|
@@ -2530,6 +2572,9 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
|
|
|
2530
2572
|
"""
|
|
2531
2573
|
if not lines:
|
|
2532
2574
|
return
|
|
2575
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2576
|
+
_MANAGED_WSL_POLICY.append_output(task_id, lines)
|
|
2577
|
+
return
|
|
2533
2578
|
try:
|
|
2534
2579
|
path = _local_bind_output_log_path(task_id)
|
|
2535
2580
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -2544,6 +2589,8 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
|
|
|
2544
2589
|
|
|
2545
2590
|
def _local_bind_output_sharing_enabled(binding_id: str | None) -> bool:
|
|
2546
2591
|
"""Return the private binding's automatic transcript-sharing preference."""
|
|
2592
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2593
|
+
return False
|
|
2547
2594
|
if not binding_id:
|
|
2548
2595
|
return False
|
|
2549
2596
|
entry = _load_local_workspaces_mapping().get(binding_id)
|
|
@@ -2588,6 +2635,8 @@ def _read_local_bind_shared_output_snapshot(task_id: str) -> dict | None:
|
|
|
2588
2635
|
|
|
2589
2636
|
def _prepare_local_bind_completion(task: "TaskInfo", result: "TaskResult") -> None:
|
|
2590
2637
|
"""Attach the automatic private snapshot before stripping normal local output."""
|
|
2638
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2639
|
+
result.shared_local_output = None
|
|
2591
2640
|
if _local_bind_output_sharing_enabled(task.local_workspace_binding_id):
|
|
2592
2641
|
result.shared_local_output = _read_local_bind_shared_output_snapshot(task.task_id)
|
|
2593
2642
|
_filter_local_bind_task_result(result)
|
|
@@ -2719,6 +2768,11 @@ def _normalize_repo_identity(repo_url: str) -> str | None:
|
|
|
2719
2768
|
|
|
2720
2769
|
|
|
2721
2770
|
async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id: str) -> str | None:
|
|
2771
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2772
|
+
_MANAGED_WSL_POLICY.check_mapping()
|
|
2773
|
+
expected = _MANAGED_WSL_POLICY.config.binding
|
|
2774
|
+
if runtime_id != _MANAGED_WSL_POLICY.runtime_id or not expected or any(binding.get(key) != value for key, value in expected.items()):
|
|
2775
|
+
return "mapping_mismatch"
|
|
2722
2776
|
binding_id = str(binding.get("id") or "")
|
|
2723
2777
|
entry = _load_local_workspaces_mapping().get(binding_id)
|
|
2724
2778
|
if not binding_id or not isinstance(entry, dict):
|
|
@@ -2746,8 +2800,8 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
|
|
|
2746
2800
|
|
|
2747
2801
|
proc = None
|
|
2748
2802
|
try:
|
|
2749
|
-
proc = await
|
|
2750
|
-
"git", "remote", "get-url", "origin",
|
|
2803
|
+
proc = await _create_subprocess_exec(
|
|
2804
|
+
_resolve_git_command() if _MANAGED_WSL_POLICY is not None else "git", "remote", "get-url", "origin",
|
|
2751
2805
|
cwd=str(local_path),
|
|
2752
2806
|
stdout=asyncio.subprocess.PIPE,
|
|
2753
2807
|
stderr=asyncio.subprocess.DEVNULL,
|
|
@@ -2826,6 +2880,8 @@ def _try_acquire_local_workspace_file_lock(binding_id: str, workspace_path: Path
|
|
|
2826
2880
|
|
|
2827
2881
|
|
|
2828
2882
|
def _release_local_workspace_file_lock(lock_file) -> None:
|
|
2883
|
+
if _MANAGED_WSL_POLICY is not None and lock_file is _MANAGED_WSL_POLICY.task_lock:
|
|
2884
|
+
return # Only verified empty-tree cleanup releases the inherited locks.
|
|
2829
2885
|
agent_core.release_file_lock(lock_file)
|
|
2830
2886
|
|
|
2831
2887
|
|
|
@@ -3573,6 +3629,9 @@ class WorkspaceManager:
|
|
|
3573
3629
|
fails closed with a "local_binding_unavailable:" prefixed error. See
|
|
3574
3630
|
docs/designs/forgexa-ide-design.md §6.2.
|
|
3575
3631
|
"""
|
|
3632
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
3633
|
+
_MANAGED_WSL_POLICY.validate_task(task, runtime_id)
|
|
3634
|
+
_MANAGED_WSL_POLICY.check_mapping()
|
|
3576
3635
|
binding_id = task.local_workspace_binding_id
|
|
3577
3636
|
if not binding_id:
|
|
3578
3637
|
raise RuntimeError("local_binding_unavailable: task has no local_workspace_binding_id")
|
|
@@ -4069,7 +4128,7 @@ class WorkspaceManager:
|
|
|
4069
4128
|
# Warn if the clone has uncommitted changes so the operator knows
|
|
4070
4129
|
# what will be lost when it is removed (L3 fix).
|
|
4071
4130
|
try:
|
|
4072
|
-
st_proc = await
|
|
4131
|
+
st_proc = await _create_subprocess_exec(
|
|
4073
4132
|
"git", "status", "--porcelain",
|
|
4074
4133
|
cwd=str(ws_path),
|
|
4075
4134
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -4242,7 +4301,7 @@ class WorkspaceManager:
|
|
|
4242
4301
|
"""
|
|
4243
4302
|
try:
|
|
4244
4303
|
# Get the local HEAD SHA
|
|
4245
|
-
local_proc = await
|
|
4304
|
+
local_proc = await _create_subprocess_exec(
|
|
4246
4305
|
"git", "rev-parse", "HEAD",
|
|
4247
4306
|
cwd=str(repo_path),
|
|
4248
4307
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -4271,7 +4330,7 @@ class WorkspaceManager:
|
|
|
4271
4330
|
# If HEAD is ahead of the merge-base, we count local-only commits — any
|
|
4272
4331
|
# count > 0 means we should NOT call the branch-r check.
|
|
4273
4332
|
try:
|
|
4274
|
-
ahead_proc = await
|
|
4333
|
+
ahead_proc = await _create_subprocess_exec(
|
|
4275
4334
|
"git", "rev-list", "--count", "--right-only", "origin...HEAD",
|
|
4276
4335
|
cwd=str(repo_path),
|
|
4277
4336
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -4293,7 +4352,7 @@ class WorkspaceManager:
|
|
|
4293
4352
|
except Exception:
|
|
4294
4353
|
pass # If the ahead count fails, proceed with the branch-r check
|
|
4295
4354
|
|
|
4296
|
-
check_proc = await
|
|
4355
|
+
check_proc = await _create_subprocess_exec(
|
|
4297
4356
|
"git", "branch", "-r", "--contains", local_head,
|
|
4298
4357
|
cwd=str(repo_path),
|
|
4299
4358
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -5184,7 +5243,7 @@ class WorkspaceManager:
|
|
|
5184
5243
|
last_branch_fetch_error: str | None = None
|
|
5185
5244
|
for _check_attempt in range(3 if not fresh_start else 1):
|
|
5186
5245
|
try:
|
|
5187
|
-
check_proc = await
|
|
5246
|
+
check_proc = await _create_subprocess_exec(
|
|
5188
5247
|
"git", "rev-parse", "--verify", f"refs/remotes/origin/{branch_name}",
|
|
5189
5248
|
cwd=str(main_repo),
|
|
5190
5249
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -5684,7 +5743,7 @@ class WorkspaceManager:
|
|
|
5684
5743
|
# orphaned processes from keeping pipes alive.
|
|
5685
5744
|
# Windows note: start_new_session creates a new console process group;
|
|
5686
5745
|
# we use taskkill /T there instead of killpg.
|
|
5687
|
-
proc = await
|
|
5746
|
+
proc = await _create_subprocess_exec(
|
|
5688
5747
|
git_command, *longpath_args, *git_prefix_args, *args,
|
|
5689
5748
|
stdin=asyncio.subprocess.DEVNULL,
|
|
5690
5749
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -5709,7 +5768,9 @@ class WorkspaceManager:
|
|
|
5709
5768
|
# ssh grandchild becomes orphaned, keeps the stderr pipe open,
|
|
5710
5769
|
# and proc.communicate() can never return EOF.
|
|
5711
5770
|
try:
|
|
5712
|
-
if
|
|
5771
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
5772
|
+
_kill_proc(proc)
|
|
5773
|
+
elif sys.platform != "win32":
|
|
5713
5774
|
import signal as _signal
|
|
5714
5775
|
try:
|
|
5715
5776
|
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
|
@@ -6525,10 +6586,11 @@ class ProcessManager:
|
|
|
6525
6586
|
_last_activity_at: list[float] = [time.monotonic()]
|
|
6526
6587
|
|
|
6527
6588
|
# Write prompt and close stdin so the agent knows input is done.
|
|
6528
|
-
if
|
|
6589
|
+
if proc.stdin:
|
|
6529
6590
|
try:
|
|
6530
|
-
|
|
6531
|
-
|
|
6591
|
+
if stdin_input:
|
|
6592
|
+
proc.stdin.write(stdin_input)
|
|
6593
|
+
await proc.stdin.drain()
|
|
6532
6594
|
except Exception:
|
|
6533
6595
|
pass
|
|
6534
6596
|
proc.stdin.close()
|
|
@@ -6751,7 +6813,7 @@ class ProcessManager:
|
|
|
6751
6813
|
env.update(self._prepare_claude_environment(sandbox_root=Path(_claude_home)))
|
|
6752
6814
|
|
|
6753
6815
|
try:
|
|
6754
|
-
proc = await
|
|
6816
|
+
proc = await _create_subprocess_exec(
|
|
6755
6817
|
*cmd,
|
|
6756
6818
|
stdout=asyncio.subprocess.PIPE,
|
|
6757
6819
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -7668,7 +7730,8 @@ class ProcessManager:
|
|
|
7668
7730
|
explicitly configured supported token environment variable.
|
|
7669
7731
|
|
|
7670
7732
|
Flags:
|
|
7671
|
-
|
|
7733
|
+
stdin Full non-interactive prompt, avoiding argv limits.
|
|
7734
|
+
-p / --prompt Short task-file instruction for Windows wrappers.
|
|
7672
7735
|
--output-format json JSONL stream of session events.
|
|
7673
7736
|
--allow-all Grant all tool + path + URL permissions required
|
|
7674
7737
|
for autonomous file-editing tasks.
|
|
@@ -7768,11 +7831,13 @@ class ProcessManager:
|
|
|
7768
7831
|
_effective_prompt, _task_file = _redirect_prompt_through_task_file(
|
|
7769
7832
|
cwd, prompt, task_id, "Copilot",
|
|
7770
7833
|
)
|
|
7834
|
+
_stdin_input = None if _task_file is not None else prompt.encode("utf-8")
|
|
7771
7835
|
|
|
7772
7836
|
cmd = agent_core.build_copilot_command(
|
|
7773
7837
|
agent.command,
|
|
7774
7838
|
cwd=cwd,
|
|
7775
7839
|
prompt=_effective_prompt,
|
|
7840
|
+
prompt_via_stdin=_stdin_input is not None,
|
|
7776
7841
|
model=model_override,
|
|
7777
7842
|
reasoning=reasoning if not _retry_without_effort else None,
|
|
7778
7843
|
disable_builtin_mcps=disable_builtin_mcps,
|
|
@@ -7786,9 +7851,20 @@ class ProcessManager:
|
|
|
7786
7851
|
# reproduce failures manually and diagnose CLI version incompatibilities.
|
|
7787
7852
|
_cmd_display = " ".join(
|
|
7788
7853
|
f'"{a}"' if " " in a else a
|
|
7789
|
-
for a in cmd
|
|
7790
|
-
)
|
|
7791
|
-
|
|
7854
|
+
for a in (cmd if _stdin_input is not None else cmd[:-1])
|
|
7855
|
+
)
|
|
7856
|
+
if _stdin_input is None:
|
|
7857
|
+
_cmd_display += ' "<prompt>"'
|
|
7858
|
+
logger.debug(
|
|
7859
|
+
"Copilot invocation for task %s: %s (prompt via %s)",
|
|
7860
|
+
task_id, _cmd_display, "stdin" if _stdin_input is not None else "task file",
|
|
7861
|
+
)
|
|
7862
|
+
logger.info(
|
|
7863
|
+
"Copilot task %s prompt transport: %s (%d UTF-8 bytes)",
|
|
7864
|
+
task_id,
|
|
7865
|
+
"stdin" if _stdin_input is not None else "task file",
|
|
7866
|
+
len(_stdin_input) if _stdin_input is not None else len(prompt.encode("utf-8")),
|
|
7867
|
+
)
|
|
7792
7868
|
|
|
7793
7869
|
# On Windows, CREATE_NO_WINDOW ensures GetConsoleWindow() returns NULL for
|
|
7794
7870
|
# the child process, even when the parent (daemon or Tauri app) has a console
|
|
@@ -7796,9 +7872,9 @@ class ProcessManager:
|
|
|
7796
7872
|
# check via GetConsoleWindow(): if a console window is visible, it uses only
|
|
7797
7873
|
# the first line of -p as a brief context note and reports "No task was
|
|
7798
7874
|
# provided" for the rest of the prompt — silently discarding the full task.
|
|
7799
|
-
#
|
|
7875
|
+
# A pipe or DEVNULL covers the primary check (GetConsoleMode on stdin);
|
|
7800
7876
|
# CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
|
|
7801
|
-
# headless environment and processes
|
|
7877
|
+
# headless environment and processes the complete task specification.
|
|
7802
7878
|
proc: asyncio.subprocess.Process | None = None
|
|
7803
7879
|
|
|
7804
7880
|
async def _terminate_copilot_process(
|
|
@@ -7813,22 +7889,15 @@ class ProcessManager:
|
|
|
7813
7889
|
pass
|
|
7814
7890
|
|
|
7815
7891
|
try:
|
|
7816
|
-
proc = await
|
|
7892
|
+
proc = await _create_subprocess_exec(
|
|
7817
7893
|
*cmd,
|
|
7818
7894
|
stdout=asyncio.subprocess.PIPE,
|
|
7819
7895
|
stderr=asyncio.subprocess.PIPE,
|
|
7820
|
-
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
# and enters interactive/session-check mode, ignoring -p as a task:
|
|
7826
|
-
# it checks the session todo table (empty), reports "no tracked work
|
|
7827
|
-
# items", and waits for user input instead of executing the task.
|
|
7828
|
-
# On Linux/macOS (daemon as service), stdin is typically /dev/null so
|
|
7829
|
-
# the bug doesn't manifest. Explicit DEVNULL ensures non-interactive
|
|
7830
|
-
# mode on all platforms, matching the intended headless operation.
|
|
7831
|
-
stdin=asyncio.subprocess.DEVNULL,
|
|
7896
|
+
stdin=(
|
|
7897
|
+
asyncio.subprocess.PIPE
|
|
7898
|
+
if _stdin_input is not None
|
|
7899
|
+
else asyncio.subprocess.DEVNULL
|
|
7900
|
+
),
|
|
7832
7901
|
cwd=str(cwd),
|
|
7833
7902
|
env=env,
|
|
7834
7903
|
limit=100 * 1024 * 1024,
|
|
@@ -7836,7 +7905,7 @@ class ProcessManager:
|
|
|
7836
7905
|
)
|
|
7837
7906
|
self.active_processes[task_id] = proc
|
|
7838
7907
|
stdout, stderr, returncode = await self._stream_process(
|
|
7839
|
-
proc,
|
|
7908
|
+
proc, _stdin_input, timeout, task_id, on_chunk,
|
|
7840
7909
|
workspace_path=cwd,
|
|
7841
7910
|
stream_stderr=stream_stderr,
|
|
7842
7911
|
)
|
|
@@ -8213,7 +8282,7 @@ class ProcessManager:
|
|
|
8213
8282
|
proc = None
|
|
8214
8283
|
try:
|
|
8215
8284
|
cmd = _prepare_agent_command(cmd)
|
|
8216
|
-
proc = await
|
|
8285
|
+
proc = await _create_subprocess_exec(
|
|
8217
8286
|
*cmd,
|
|
8218
8287
|
stdout=asyncio.subprocess.PIPE,
|
|
8219
8288
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8305,7 +8374,7 @@ class ProcessManager:
|
|
|
8305
8374
|
"""Collect git diff stats from workspace."""
|
|
8306
8375
|
info: dict[str, Any] = {}
|
|
8307
8376
|
try:
|
|
8308
|
-
proc = await
|
|
8377
|
+
proc = await _create_subprocess_exec(
|
|
8309
8378
|
"git", "diff", "--stat", "--cached",
|
|
8310
8379
|
stdout=asyncio.subprocess.PIPE,
|
|
8311
8380
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8316,7 +8385,7 @@ class ProcessManager:
|
|
|
8316
8385
|
stat_output = stdout.decode()
|
|
8317
8386
|
|
|
8318
8387
|
# Also check unstaged
|
|
8319
|
-
proc2 = await
|
|
8388
|
+
proc2 = await _create_subprocess_exec(
|
|
8320
8389
|
"git", "diff", "--stat",
|
|
8321
8390
|
stdout=asyncio.subprocess.PIPE,
|
|
8322
8391
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8327,7 +8396,7 @@ class ProcessManager:
|
|
|
8327
8396
|
stat_output += stdout2.decode()
|
|
8328
8397
|
|
|
8329
8398
|
# Parse changed files
|
|
8330
|
-
proc3 = await
|
|
8399
|
+
proc3 = await _create_subprocess_exec(
|
|
8331
8400
|
"git", "diff", "--name-only", "HEAD",
|
|
8332
8401
|
stdout=asyncio.subprocess.PIPE,
|
|
8333
8402
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8339,7 +8408,7 @@ class ProcessManager:
|
|
|
8339
8408
|
info["files_changed"] = files
|
|
8340
8409
|
|
|
8341
8410
|
# Parse insertions/deletions
|
|
8342
|
-
proc4 = await
|
|
8411
|
+
proc4 = await _create_subprocess_exec(
|
|
8343
8412
|
"git", "diff", "--shortstat", "HEAD",
|
|
8344
8413
|
stdout=asyncio.subprocess.PIPE,
|
|
8345
8414
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8354,7 +8423,7 @@ class ProcessManager:
|
|
|
8354
8423
|
info["lines_removed"] = int(removed.group(1)) if removed else 0
|
|
8355
8424
|
|
|
8356
8425
|
# Get current branch
|
|
8357
|
-
proc5 = await
|
|
8426
|
+
proc5 = await _create_subprocess_exec(
|
|
8358
8427
|
"git", "rev-parse", "--abbrev-ref", "HEAD",
|
|
8359
8428
|
stdout=asyncio.subprocess.PIPE,
|
|
8360
8429
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8365,7 +8434,7 @@ class ProcessManager:
|
|
|
8365
8434
|
info["branch"] = stdout5.decode().strip()
|
|
8366
8435
|
|
|
8367
8436
|
# Get latest commit
|
|
8368
|
-
proc6 = await
|
|
8437
|
+
proc6 = await _create_subprocess_exec(
|
|
8369
8438
|
"git", "rev-parse", "HEAD",
|
|
8370
8439
|
stdout=asyncio.subprocess.PIPE,
|
|
8371
8440
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8411,7 +8480,7 @@ class ProcessManager:
|
|
|
8411
8480
|
candidate_refs.append(ref)
|
|
8412
8481
|
|
|
8413
8482
|
for candidate in candidate_refs:
|
|
8414
|
-
proc_mb = await
|
|
8483
|
+
proc_mb = await _create_subprocess_exec(
|
|
8415
8484
|
"git", "merge-base", candidate, "HEAD",
|
|
8416
8485
|
stdout=asyncio.subprocess.PIPE,
|
|
8417
8486
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8424,7 +8493,7 @@ class ProcessManager:
|
|
|
8424
8493
|
break
|
|
8425
8494
|
|
|
8426
8495
|
# Changed files since branch point
|
|
8427
|
-
proc = await
|
|
8496
|
+
proc = await _create_subprocess_exec(
|
|
8428
8497
|
"git", "diff", "--name-only", base_ref, "HEAD",
|
|
8429
8498
|
stdout=asyncio.subprocess.PIPE,
|
|
8430
8499
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8436,7 +8505,7 @@ class ProcessManager:
|
|
|
8436
8505
|
info["files_changed"] = files
|
|
8437
8506
|
|
|
8438
8507
|
# Lines added/removed since branch point
|
|
8439
|
-
proc2 = await
|
|
8508
|
+
proc2 = await _create_subprocess_exec(
|
|
8440
8509
|
"git", "diff", "--shortstat", base_ref, "HEAD",
|
|
8441
8510
|
stdout=asyncio.subprocess.PIPE,
|
|
8442
8511
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8451,7 +8520,7 @@ class ProcessManager:
|
|
|
8451
8520
|
info["lines_removed"] = int(removed.group(1)) if removed else 0
|
|
8452
8521
|
|
|
8453
8522
|
# Branch name
|
|
8454
|
-
proc3 = await
|
|
8523
|
+
proc3 = await _create_subprocess_exec(
|
|
8455
8524
|
"git", "rev-parse", "--abbrev-ref", "HEAD",
|
|
8456
8525
|
stdout=asyncio.subprocess.PIPE,
|
|
8457
8526
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8462,7 +8531,7 @@ class ProcessManager:
|
|
|
8462
8531
|
info["branch"] = stdout3.decode().strip()
|
|
8463
8532
|
|
|
8464
8533
|
# Commit SHA
|
|
8465
|
-
proc4 = await
|
|
8534
|
+
proc4 = await _create_subprocess_exec(
|
|
8466
8535
|
"git", "rev-parse", "HEAD",
|
|
8467
8536
|
stdout=asyncio.subprocess.PIPE,
|
|
8468
8537
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8779,7 +8848,7 @@ class RuntimePreflightMonitor:
|
|
|
8779
8848
|
process group SIGKILL on POSIX) and waits for reaping before returning.
|
|
8780
8849
|
"""
|
|
8781
8850
|
try:
|
|
8782
|
-
proc = await
|
|
8851
|
+
proc = await _create_subprocess_exec(
|
|
8783
8852
|
*cmd,
|
|
8784
8853
|
stdin=asyncio.subprocess.DEVNULL,
|
|
8785
8854
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -8804,6 +8873,9 @@ class RuntimePreflightMonitor:
|
|
|
8804
8873
|
@staticmethod
|
|
8805
8874
|
def _kill_process_tree(proc):
|
|
8806
8875
|
"""Best-effort kill of the entire process subtree."""
|
|
8876
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
8877
|
+
_kill_proc(proc)
|
|
8878
|
+
return
|
|
8807
8879
|
try:
|
|
8808
8880
|
if sys.platform == "win32":
|
|
8809
8881
|
# Must be a BLOCKING call here: this is a plain (non-async)
|
|
@@ -9006,6 +9078,8 @@ class HeartbeatService:
|
|
|
9006
9078
|
self._agents = [dict(agent) for agent in agents]
|
|
9007
9079
|
|
|
9008
9080
|
async def _sync_local_workspace_bindings(self) -> None:
|
|
9081
|
+
if _MANAGED_WSL_POLICY is not None and _MANAGED_WSL_POLICY.task is not None:
|
|
9082
|
+
return # The task already retains mapping admission and the physical lock.
|
|
9009
9083
|
try:
|
|
9010
9084
|
response = await self.client.get(
|
|
9011
9085
|
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/verification",
|
|
@@ -9025,6 +9099,9 @@ class HeartbeatService:
|
|
|
9025
9099
|
for binding in bindings:
|
|
9026
9100
|
if not isinstance(binding, dict) or not binding.get("id"):
|
|
9027
9101
|
continue
|
|
9102
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
9103
|
+
await _MANAGED_WSL_POLICY.sync_binding(self, binding)
|
|
9104
|
+
continue
|
|
9028
9105
|
failure_code = await _validate_local_workspace_binding(binding, self.runtime_id)
|
|
9029
9106
|
if failure_code is None and binding.get("status") == "active":
|
|
9030
9107
|
continue
|
|
@@ -9151,6 +9228,8 @@ class LogUploader:
|
|
|
9151
9228
|
|
|
9152
9229
|
async def _upload(self):
|
|
9153
9230
|
"""Read daemon log tail and upload to server."""
|
|
9231
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
9232
|
+
return # Private WSL logs are never uploaded, even when no task is active.
|
|
9154
9233
|
try:
|
|
9155
9234
|
if not DAEMON_LOG_PATH.exists():
|
|
9156
9235
|
return
|
|
@@ -9344,6 +9423,11 @@ class ServerConnection:
|
|
|
9344
9423
|
# honored here, which otherwise turns loopback calls into 502s.
|
|
9345
9424
|
trust_env=False,
|
|
9346
9425
|
)
|
|
9426
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
9427
|
+
if (server_url != _MANAGED_WSL_POLICY.config.serverUrl or api_token != _MANAGED_WSL_POLICY.credential
|
|
9428
|
+
or daemon_id != _MANAGED_WSL_POLICY.daemon_id or hardware_id != _MANAGED_WSL_POLICY.hardware_id):
|
|
9429
|
+
raise ValueError("wsl_runtime_connection_invalid")
|
|
9430
|
+
self.client = _wsl_policy_module.PrivateClient(self.client, _MANAGED_WSL_POLICY)
|
|
9347
9431
|
self.heartbeat: HeartbeatService | None = None
|
|
9348
9432
|
self.poller: TaskPoller | None = None
|
|
9349
9433
|
self.reporter: ProgressReporter | None = None
|
|
@@ -9901,6 +9985,8 @@ class ServerConnection:
|
|
|
9901
9985
|
raise DaemonCredentialUpgradeRequiredError(code)
|
|
9902
9986
|
resp.raise_for_status()
|
|
9903
9987
|
data = resp.json()
|
|
9988
|
+
if _MANAGED_WSL_POLICY is not None and data.get("runtime_id") != _MANAGED_WSL_POLICY.runtime_id:
|
|
9989
|
+
raise DaemonCredentialUpgradeRequiredError("runtime_credential_runtime_mismatch")
|
|
9904
9990
|
self.runtime_id = data["runtime_id"]
|
|
9905
9991
|
logger.info("[%s] Registered with server. runtime_id=%s", self.label, self.runtime_id)
|
|
9906
9992
|
self._publish_local_runtime_registration()
|
|
@@ -10599,23 +10685,26 @@ class RuntimeDaemon:
|
|
|
10599
10685
|
_TASK_ATTACHMENT_MAX_COUNT = 20
|
|
10600
10686
|
_TASK_ATTACHMENT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
|
10601
10687
|
|
|
10602
|
-
def __init__(self):
|
|
10688
|
+
def __init__(self, *, managed_policy=None):
|
|
10689
|
+
if managed_policy is not _MANAGED_WSL_POLICY:
|
|
10690
|
+
raise ValueError("wsl_runtime_entry_not_admitted")
|
|
10691
|
+
self.managed_policy = managed_policy
|
|
10603
10692
|
# Compute stable hardware fingerprint from machine-id / MAC address.
|
|
10604
10693
|
# This allows the server to identify the same physical machine even when
|
|
10605
10694
|
# its IP address changes (e.g., DHCP reassignment, home vs. office).
|
|
10606
|
-
self.hardware_id = get_hardware_id()
|
|
10695
|
+
self.hardware_id = managed_policy.hardware_id if managed_policy is not None else get_hardware_id()
|
|
10607
10696
|
|
|
10608
10697
|
# daemon_id: prefer explicit config, fall back to hardware_id (stable),
|
|
10609
10698
|
# then hostname. Using hardware_id as default instead of platform.node()
|
|
10610
10699
|
# prevents duplicate registrations when the hostname changes.
|
|
10611
|
-
self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
|
|
10612
|
-
self.server_urls = settings.get_daemon_server_urls()
|
|
10613
|
-
self.api_token = _configured_daemon_api_token()
|
|
10700
|
+
self.daemon_id = managed_policy.daemon_id if managed_policy is not None else settings.DAEMON_ID or self.hardware_id or platform.node()
|
|
10701
|
+
self.server_urls = [managed_policy.config.serverUrl] if managed_policy is not None else settings.get_daemon_server_urls()
|
|
10702
|
+
self.api_token = managed_policy.credential if managed_policy is not None else _configured_daemon_api_token()
|
|
10614
10703
|
self._has_explicit_api_token = bool(self.api_token)
|
|
10615
|
-
self.max_concurrent = settings.DAEMON_MAX_CONCURRENT
|
|
10704
|
+
self.max_concurrent = 1 if managed_policy is not None else settings.DAEMON_MAX_CONCURRENT
|
|
10616
10705
|
self.poll_interval = settings.DAEMON_POLL_INTERVAL
|
|
10617
10706
|
self.heartbeat_interval = settings.DAEMON_HEARTBEAT_INTERVAL
|
|
10618
|
-
self.workspaces_root = settings.get_daemon_workspaces_root()
|
|
10707
|
+
self.workspaces_root = str(managed_policy.state / "workspaces") if managed_policy is not None else settings.get_daemon_workspaces_root()
|
|
10619
10708
|
|
|
10620
10709
|
self.agents: list[DiscoveredAgent] = []
|
|
10621
10710
|
self.active_tasks: dict[str, asyncio.Task] = {}
|
|
@@ -10668,12 +10757,16 @@ class RuntimeDaemon:
|
|
|
10668
10757
|
|
|
10669
10758
|
async def _refresh_agents(self) -> None:
|
|
10670
10759
|
"""Re-discover local CLIs so upgrades reach the runtime heartbeat."""
|
|
10760
|
+
if self.managed_policy is not None and self.active_tasks:
|
|
10761
|
+
return
|
|
10671
10762
|
try:
|
|
10672
10763
|
refreshed_agents = await self.agent_discovery.discover()
|
|
10673
10764
|
except Exception as exc:
|
|
10674
10765
|
logger.warning("Agent inventory refresh failed: %s", exc)
|
|
10675
10766
|
return
|
|
10676
10767
|
|
|
10768
|
+
if self.managed_policy is not None:
|
|
10769
|
+
refreshed_agents = [agent for agent in refreshed_agents if agent.agent_id in self.managed_policy.drivers]
|
|
10677
10770
|
if refreshed_agents == self.agents:
|
|
10678
10771
|
return
|
|
10679
10772
|
|
|
@@ -10717,6 +10810,8 @@ class RuntimeDaemon:
|
|
|
10717
10810
|
)
|
|
10718
10811
|
|
|
10719
10812
|
def _begin_desktop_restart_drain_if_requested(self) -> bool:
|
|
10813
|
+
if self.managed_policy is not None:
|
|
10814
|
+
return False
|
|
10720
10815
|
target_version = _desktop_daemon_restart_target_version(
|
|
10721
10816
|
self._local_runtime_instance_id
|
|
10722
10817
|
)
|
|
@@ -11484,13 +11579,14 @@ class RuntimeDaemon:
|
|
|
11484
11579
|
async def start(self):
|
|
11485
11580
|
"""Main entry point."""
|
|
11486
11581
|
# Prevent multiple daemon instances on the same machine
|
|
11487
|
-
self.
|
|
11582
|
+
if self.managed_policy is None:
|
|
11583
|
+
self._acquire_lock()
|
|
11488
11584
|
_write_local_runtime_registration_snapshot(
|
|
11489
11585
|
self._local_runtime_instance_id,
|
|
11490
11586
|
"starting",
|
|
11491
11587
|
{},
|
|
11492
11588
|
)
|
|
11493
|
-
cleaned_processes = self._cleanup_stale_agent_processes()
|
|
11589
|
+
cleaned_processes = self._cleanup_stale_agent_processes() if self.managed_policy is None else 0
|
|
11494
11590
|
if cleaned_processes:
|
|
11495
11591
|
logger.warning(
|
|
11496
11592
|
"Cleaned up %d orphaned Forgexa agent process group(s)",
|
|
@@ -11507,6 +11603,8 @@ class RuntimeDaemon:
|
|
|
11507
11603
|
|
|
11508
11604
|
# 1. Discover agents
|
|
11509
11605
|
self.agents = await self.agent_discovery.discover()
|
|
11606
|
+
if self.managed_policy is not None:
|
|
11607
|
+
self.agents = [agent for agent in self.agents if agent.agent_id in self.managed_policy.drivers]
|
|
11510
11608
|
if not self.agents:
|
|
11511
11609
|
logger.warning("No agent CLIs found. Daemon will run but cannot execute tasks.")
|
|
11512
11610
|
else:
|
|
@@ -11601,7 +11699,8 @@ class RuntimeDaemon:
|
|
|
11601
11699
|
|
|
11602
11700
|
# Clean up stale Copilot/Claude temp directories left by prior OOM-killed
|
|
11603
11701
|
# daemon processes. This runs once at startup so /tmp doesn't fill up.
|
|
11604
|
-
|
|
11702
|
+
if self.managed_policy is None:
|
|
11703
|
+
await self._cleanup_stale_tmp_dirs()
|
|
11605
11704
|
|
|
11606
11705
|
# 3. Main loop
|
|
11607
11706
|
try:
|
|
@@ -11649,9 +11748,10 @@ class RuntimeDaemon:
|
|
|
11649
11748
|
|
|
11650
11749
|
# Opportunistic GC: remove old deploy-merge worktrees (throttled internally).
|
|
11651
11750
|
try:
|
|
11652
|
-
|
|
11653
|
-
|
|
11654
|
-
|
|
11751
|
+
if self.managed_policy is None:
|
|
11752
|
+
await self._cleanup_old_merge_worktrees(
|
|
11753
|
+
self.connections[0] if self.connections else None
|
|
11754
|
+
)
|
|
11655
11755
|
except Exception:
|
|
11656
11756
|
pass # GC failure must never block the poll loop
|
|
11657
11757
|
|
|
@@ -11707,7 +11807,7 @@ class RuntimeDaemon:
|
|
|
11707
11807
|
)
|
|
11708
11808
|
# Always poll AI jobs so server-requested cancellations can stop a
|
|
11709
11809
|
# process that is itself consuming the last available slot.
|
|
11710
|
-
ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks else []
|
|
11810
|
+
ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks and self.managed_policy is None else []
|
|
11711
11811
|
_cancel_server_requested_execution_tasks(conn)
|
|
11712
11812
|
_cancel_server_requested_ai_jobs(conn)
|
|
11713
11813
|
total_active = len(self.active_tasks)
|
|
@@ -12051,8 +12151,23 @@ class RuntimeDaemon:
|
|
|
12051
12151
|
async def report_task_complete(result: TaskResult) -> None:
|
|
12052
12152
|
if is_local_bind:
|
|
12053
12153
|
_prepare_local_bind_completion(task, result)
|
|
12154
|
+
if self.managed_policy is not None and not await self.managed_policy.finish_task():
|
|
12155
|
+
result.status = "failed"
|
|
12156
|
+
result.failure_code = "wsl_runtime_recovery_required"
|
|
12157
|
+
result.error = "Managed local writer cleanup is unconfirmed"
|
|
12054
12158
|
await reporter.report_complete(task.task_id, result)
|
|
12055
12159
|
|
|
12160
|
+
if self.managed_policy is not None:
|
|
12161
|
+
try:
|
|
12162
|
+
await self.managed_policy.authorize_task(task, conn)
|
|
12163
|
+
except ValueError as exc:
|
|
12164
|
+
await report_task_complete(TaskResult(
|
|
12165
|
+
status="failed", exit_code=-1, stdout="", stderr="",
|
|
12166
|
+
error="Managed local dispatch was refused", failure_code=getattr(exc, "code", "wsl_runtime_dispatch_refused"),
|
|
12167
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12168
|
+
))
|
|
12169
|
+
return
|
|
12170
|
+
|
|
12056
12171
|
default_branch = str((task.project or {}).get("default_branch") or "").strip()
|
|
12057
12172
|
node_before_sha = ""
|
|
12058
12173
|
# Use workspace-level granularity (requirement_workflow_id or graph_id) so that
|
|
@@ -12082,9 +12197,8 @@ class RuntimeDaemon:
|
|
|
12082
12197
|
# but this is a separate, complementary guard against two
|
|
12083
12198
|
# DAEMON PROCESSES (e.g. a zombie/restarted instance) operating
|
|
12084
12199
|
# on the same real directory at the same time.
|
|
12085
|
-
local_bind_file_lock =
|
|
12086
|
-
|
|
12087
|
-
)
|
|
12200
|
+
local_bind_file_lock = (self.managed_policy.task_lock if self.managed_policy is not None
|
|
12201
|
+
else _try_acquire_local_workspace_file_lock(task.local_workspace_binding_id))
|
|
12088
12202
|
if local_bind_file_lock is None:
|
|
12089
12203
|
project_lock.release()
|
|
12090
12204
|
await report_task_complete(TaskResult(
|
|
@@ -12970,13 +13084,19 @@ class RuntimeDaemon:
|
|
|
12970
13084
|
logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
|
|
12971
13085
|
|
|
12972
13086
|
except Exception as e:
|
|
12973
|
-
|
|
13087
|
+
if self.managed_policy is not None:
|
|
13088
|
+
_append_local_bind_output_log(task.task_id, [str(e)])
|
|
13089
|
+
logger.warning("Managed local task failed: %s", getattr(e, "code", "local_task_failed"))
|
|
13090
|
+
else:
|
|
13091
|
+
logger.exception("Task %s failed with exception", task.task_id)
|
|
12974
13092
|
await report_task_complete(TaskResult(
|
|
12975
13093
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
12976
13094
|
error=str(e),
|
|
12977
13095
|
execution_attempt_id=task.execution_attempt_id,
|
|
12978
13096
|
))
|
|
12979
13097
|
finally:
|
|
13098
|
+
if self.managed_policy is not None:
|
|
13099
|
+
await self.managed_policy.finish_task()
|
|
12980
13100
|
if attachment_staging_dir is not None:
|
|
12981
13101
|
shutil.rmtree(attachment_staging_dir, ignore_errors=True)
|
|
12982
13102
|
if workspace_execution_lock is not None:
|
|
@@ -15633,7 +15753,7 @@ class RuntimeDaemon:
|
|
|
15633
15753
|
# Unstage if it was already staged
|
|
15634
15754
|
if is_staged_new:
|
|
15635
15755
|
try:
|
|
15636
|
-
proc = await
|
|
15756
|
+
proc = await _create_subprocess_exec(
|
|
15637
15757
|
"git", "restore", "--staged", file_path,
|
|
15638
15758
|
cwd=str(workspace_path),
|
|
15639
15759
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -15688,7 +15808,7 @@ class RuntimeDaemon:
|
|
|
15688
15808
|
lost). That is legitimate, intentional work — not noise — so it
|
|
15689
15809
|
must be allowed to commit; we only log it for audit visibility.
|
|
15690
15810
|
"""
|
|
15691
|
-
proc = await
|
|
15811
|
+
proc = await _create_subprocess_exec(
|
|
15692
15812
|
"git", "diff", "--raw", "--no-abbrev", "--no-renames", "-z", *diff_args,
|
|
15693
15813
|
cwd=str(workspace_path),
|
|
15694
15814
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -15946,7 +16066,7 @@ class RuntimeDaemon:
|
|
|
15946
16066
|
except RuntimeError:
|
|
15947
16067
|
return {"commit_error": "Refusing to commit: the task baseline changed while preparing the commit"}
|
|
15948
16068
|
_git_name, _git_email = _resolve_git_author(task.project)
|
|
15949
|
-
proc = await
|
|
16069
|
+
proc = await _create_subprocess_exec(
|
|
15950
16070
|
"git", "commit", "-m", commit_msg,
|
|
15951
16071
|
cwd=str(workspace_path),
|
|
15952
16072
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16110,7 +16230,7 @@ class RuntimeDaemon:
|
|
|
16110
16230
|
return {"commit_error": mode_change_error}
|
|
16111
16231
|
|
|
16112
16232
|
# Check for uncommitted changes
|
|
16113
|
-
proc = await
|
|
16233
|
+
proc = await _create_subprocess_exec(
|
|
16114
16234
|
"git", "status", "--porcelain",
|
|
16115
16235
|
cwd=str(workspace_path),
|
|
16116
16236
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16129,7 +16249,7 @@ class RuntimeDaemon:
|
|
|
16129
16249
|
await self._remove_root_scratch_files(workspace_path, task, stdout.decode())
|
|
16130
16250
|
|
|
16131
16251
|
# Stage all changes
|
|
16132
|
-
proc = await
|
|
16252
|
+
proc = await _create_subprocess_exec(
|
|
16133
16253
|
"git", "add", "-A",
|
|
16134
16254
|
cwd=str(workspace_path),
|
|
16135
16255
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16178,7 +16298,7 @@ class RuntimeDaemon:
|
|
|
16178
16298
|
logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
|
|
16179
16299
|
commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
|
|
16180
16300
|
_git_name, _git_email = _resolve_git_author(task.project)
|
|
16181
|
-
proc = await
|
|
16301
|
+
proc = await _create_subprocess_exec(
|
|
16182
16302
|
"git", "commit", "-m", commit_msg,
|
|
16183
16303
|
cwd=str(workspace_path),
|
|
16184
16304
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16905,7 +17025,7 @@ class RuntimeDaemon:
|
|
|
16905
17025
|
"""Collect staged diff stats for building a rich commit message."""
|
|
16906
17026
|
status_map: dict[str, str] = {}
|
|
16907
17027
|
|
|
16908
|
-
proc = await
|
|
17028
|
+
proc = await _create_subprocess_exec(
|
|
16909
17029
|
"git", "diff", "--cached", "--name-status",
|
|
16910
17030
|
cwd=str(cwd),
|
|
16911
17031
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16919,7 +17039,7 @@ class RuntimeDaemon:
|
|
|
16919
17039
|
status_map[parts[1].strip()] = parts[0].strip()
|
|
16920
17040
|
|
|
16921
17041
|
files: list[dict] = []
|
|
16922
|
-
proc = await
|
|
17042
|
+
proc = await _create_subprocess_exec(
|
|
16923
17043
|
"git", "diff", "--cached", "--numstat",
|
|
16924
17044
|
cwd=str(cwd),
|
|
16925
17045
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16948,7 +17068,7 @@ class RuntimeDaemon:
|
|
|
16948
17068
|
for file_info in files[:20]:
|
|
16949
17069
|
file_info["symbols"] = await self._extract_staged_file_symbols(cwd, file_info["path"])
|
|
16950
17070
|
|
|
16951
|
-
proc = await
|
|
17071
|
+
proc = await _create_subprocess_exec(
|
|
16952
17072
|
"git", "diff", "--cached", "--stat",
|
|
16953
17073
|
cwd=str(cwd),
|
|
16954
17074
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16977,7 +17097,7 @@ class RuntimeDaemon:
|
|
|
16977
17097
|
)
|
|
16978
17098
|
|
|
16979
17099
|
try:
|
|
16980
|
-
proc = await
|
|
17100
|
+
proc = await _create_subprocess_exec(
|
|
16981
17101
|
"git", "diff", "--cached", "--unified=0", "--", filepath,
|
|
16982
17102
|
cwd=str(cwd),
|
|
16983
17103
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -18022,7 +18142,7 @@ class RuntimeDaemon:
|
|
|
18022
18142
|
resolve_result = await self.process_manager.run_agent(agent, _cr_task, workspace_path)
|
|
18023
18143
|
|
|
18024
18144
|
# 6. Check if conflicts are resolved
|
|
18025
|
-
proc = await
|
|
18145
|
+
proc = await _create_subprocess_exec(
|
|
18026
18146
|
"git", "diff", "--name-only", "--diff-filter=U",
|
|
18027
18147
|
cwd=str(workspace_path),
|
|
18028
18148
|
stdout=asyncio.subprocess.PIPE,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|