Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,13 @@ env:

# Product paths whose Windows behavior is exercised by the Desktop host.
WINDOWS_CORE_TESTS: >-
tests/apps/test_cli_parser.py
tests/apps/test_tui_launcher.py
tests/daemon/test_health.py
tests/daemon/test_process_workspace.py
tests/daemon/test_spawn_admission_portable.py
tests/daemon/test_spawn_helper.py
tests/daemon/test_windows_daemon_control.py
tests/daemon/test_windows_terminal_process.py
tests/test_agent_cli_backend.py
tests/webapi/test_commands_m1.py
Expand Down Expand Up @@ -179,7 +182,7 @@ jobs:
- name: Install
run: |
python -m pip install --upgrade pip
python -m pip install -e . pytest ruff "pyinstaller>=6.11,<7"
python -m pip install -e . pytest ruff "httpx2>=2.9,<3" "pyinstaller>=6.11,<7"
npm --prefix desktop ci
- name: Lint and test desktop sources
run: |
Expand Down
1 change: 1 addition & 0 deletions argus_skill/apps/cli/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ def build_parser() -> argparse.ArgumentParser:
)
cockpit_grp.add_argument(
"--web-host",
"--host",
default="127.0.0.1",
help="bind host for --web (default 127.0.0.1; use 0.0.0.0 to reach it "
"from a phone on the same network). A non-loopback bind always "
Expand Down
2 changes: 2 additions & 0 deletions argus_skill/apps/tui_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
"--gc-days",
"--objective",
"--web-host",
"--host",
"--web-port",
"--port",
"--notify-stage",
"--backend",
"--auth-mode",
Expand Down
28 changes: 27 additions & 1 deletion argus_skill/daemon/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
HEALTH_SCHEMA_VERSION = 1
DEFAULT_STALL_SECONDS = 30 * 60.0
_ACTIVITY_WRITE_INTERVAL_SECONDS = 5.0
_WINDOWS_REPLACE_ATTEMPTS = 6
_WINDOWS_REPLACE_INITIAL_DELAY_SECONDS = 0.01

_ACTIVE_EVENTS = frozenset({
"life.manager.intent.started",
Expand Down Expand Up @@ -60,6 +62,30 @@
})


def _running_on_windows() -> bool:
return os.name == "nt"


def _replace_atomic_file(temporary: str, path: Path) -> None:
"""Replace a health sidecar despite short-lived Windows read sharing.

Status consumers open this file frequently. On Windows a reader or security
scanner can briefly deny replacement with ``WinError 5/32`` even though the
writer owns a unique temporary file. Retry only ``PermissionError`` on
Windows, with a small bounded backoff; every other failure remains visible.
"""
delay = _WINDOWS_REPLACE_INITIAL_DELAY_SECONDS
for attempt in range(_WINDOWS_REPLACE_ATTEMPTS):
try:
os.replace(temporary, path)
return
except PermissionError:
if not _running_on_windows() or attempt + 1 >= _WINDOWS_REPLACE_ATTEMPTS:
raise
time.sleep(delay)
delay *= 2


def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
Expand All @@ -69,7 +95,7 @@ def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
_replace_atomic_file(temporary, path)
finally:
try:
os.unlink(temporary)
Expand Down
37 changes: 34 additions & 3 deletions argus_skill/daemon/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
_daemon_pid_path,
_daemon_status_path,
_daemon_status_payload,
_descendant_pids,
_new_boot_id,
_point_active_daemon_log,
_redirect_std_to_log,
Expand Down Expand Up @@ -92,6 +93,24 @@ def _windows_daemon_command(config: Any) -> list[str]:
return command


def _windows_runtime_belongs_to_launcher(
launcher_pid: int,
runtime_pid: int,
) -> bool:
"""Prove that a published worker PID belongs to the process we spawned.

A Windows virtual-environment ``python.exe`` is a launcher stub. Its PID is
the one returned by :class:`subprocess.Popen`, while the base interpreter
child acquires ``daemon.pid`` and publishes ``daemon.status.json``. Requiring
PID equality therefore rejects a healthy source-checkout worker. Keep the
foreign-status protection by accepting only the launcher itself or one of
its current descendants.
"""
if runtime_pid == launcher_pid:
return True
return runtime_pid in _descendant_pids(launcher_pid)


def _reap_failed_windows_spawn(process: subprocess.Popen[Any]) -> None:
"""Reclaim the exact worker tree after a failed publication handshake."""
pid = int(process.pid)
Expand Down Expand Up @@ -153,18 +172,25 @@ def _spawn_windows_background_process(
deadline = time.monotonic() + _WINDOWS_DAEMON_PUBLISH_TIMEOUT_SECONDS
exit_rc: int | None = None
stable_since: float | None = None
stable_pid: int | None = None
while time.monotonic() < deadline:
if pid_path.exists() and status_path.exists():
status = read_daemon_status(config.life_dir)
runtime_pid = int(status.pid or 0)
if (
status.alive
and status.pid == process.pid
and runtime_pid > 0
and _windows_runtime_belongs_to_launcher(
int(process.pid),
runtime_pid,
)
and not status.status_read_error
and process.poll() is None
):
now = time.monotonic()
if stable_since is None:
if stable_since is None or stable_pid != runtime_pid:
stable_since = now
stable_pid = runtime_pid
elif now - stable_since >= _DAEMON_STABILITY_SECONDS:
if not quiet:
sys.stdout.write(
Expand All @@ -174,6 +200,7 @@ def _spawn_windows_background_process(
return 0
else:
stable_since = None
stable_pid = None
if process.poll() is not None:
exit_rc = process.returncode
break
Expand All @@ -192,7 +219,11 @@ def _spawn_windows_background_process(
f"{_WINDOWS_DAEMON_PUBLISH_TIMEOUT_SECONDS:g}s. "
f"Check {log_path} for errors.\n"
)
return int(exit_rc) if exit_rc is not None else 2
# A zero exit before the PID/status handshake is still a failed worker:
# no process remains to execute queued work. Preserve actionable
# non-zero child codes, but never turn an unverified clean exit into a
# successful executor start.
return int(exit_rc) if exit_rc not in {None, 0} else 2
finally:
release_spawn_lock(spawn_lock_fd)

Expand Down
4 changes: 2 additions & 2 deletions argus_skill/release_manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"package_version": "0.1.1",
"release_id": "0.1.1+8729a4b0e6b82037",
"release_id": "0.1.1+f7f5995be9b92b21",
"schema_version": 1,
"source_digest": "8729a4b0e6b8203750721bd5fefae3b8612caec6127a4c580bcb8a647715d277"
"source_digest": "f7f5995be9b92b21b608f9b40b446fd71dcf4b4fe3df2f2720bc2606e88134f2"
}
1 change: 1 addition & 0 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"node": ">=22.12.0"
},
"scripts": {
"postinstall": "node -e \"require('electron')\"",
"dev": "electron-vite dev",
"typecheck": "tsc --noEmit",
"test:identity": "node -e \"require('fs').rmSync('.test-out',{recursive:true,force:true})\" && tsc --module commonjs --moduleResolution node --target ES2022 --esModuleInterop --skipLibCheck --outDir .test-out src/main/backendIdentity.ts src/main/backendProcess.ts src/main/backendResilience.ts src/main/loggerSafety.ts src/main/navigation.ts src/main/redaction.ts src/main/releaseIdentity.ts src/main/runner.ts src/renderer/ipcRecovery.ts test/backendIdentity.test.ts test/backendProcess.test.ts test/backendResilience.test.ts test/ipcRecovery.test.ts test/loggerSafety.test.ts test/navigation.test.ts test/redaction.test.ts test/releaseIdentity.test.ts test/runner.test.ts && node --test .test-out/test/*.test.js && node -e \"require('fs').rmSync('.test-out',{recursive:true,force:true})\"",
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/main/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export function createLogger(): typeof log {
log.transports.file.resolvePathFn = () =>
join(app.getPath('userData'), 'logs', 'desktop.log');
log.transports.file.maxSize = 5 * 1024 * 1024;
log.catchErrors({
log.errorHandler.startCatching({
showDialog: false,
onError(error) {
onError({ error }) {
log.error('uncaught main-process error', error);
}
});
Expand Down
4 changes: 2 additions & 2 deletions frontend/core/src/release.generated.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Generated by argus_skill.release_tools.generate_manifest. Do not edit.
export const RELEASE_ID = "0.1.1+8729a4b0e6b82037";
export const RELEASE_SOURCE_DIGEST = "8729a4b0e6b8203750721bd5fefae3b8612caec6127a4c580bcb8a647715d277";
export const RELEASE_ID = "0.1.1+f7f5995be9b92b21";
export const RELEASE_SOURCE_DIGEST = "f7f5995be9b92b21b608f9b40b446fd71dcf4b4fe3df2f2720bc2606e88134f2";
2 changes: 1 addition & 1 deletion frontend/tui/bundle/argus.mjs

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions frontend/web/dist/assets/ResearchWorkbenchPanel-BaphEhte.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

This file was deleted.

Loading
Loading