diff --git a/A.txt b/A.txt new file mode 100644 index 0000000..7898192 --- /dev/null +++ b/A.txt @@ -0,0 +1 @@ +a diff --git a/B.txt b/B.txt new file mode 100644 index 0000000..6178079 --- /dev/null +++ b/B.txt @@ -0,0 +1 @@ +b diff --git a/README-Windows.md b/README-Windows.md new file mode 100644 index 0000000..4bd6502 --- /dev/null +++ b/README-Windows.md @@ -0,0 +1,47 @@ +# ClawTeam for Windows + +This repository has been locally hardened to support native Windows use without relying on WSL for the core coordination path. + +## What works + +- Native Windows Python install +- Team creation, board display, inbox flow, task flow +- Cost/session persistence +- Team snapshots and dry-run restores +- Git worktree workspace flows +- Background agent spawning through the `windows` backend +- Web dashboard via `clawteam board serve` + +## What is different from Linux + +The original project is tmux-first. On Windows: + +- `tmux` pane tiling/attach is not available natively +- the recommended runtime is the `windows` backend, which maps to subprocess spawning +- the Web board is the preferred live monitoring view + +## Recommended commands + +```powershell +python -m pip install -e . +python -m clawteam config set default_backend windows +python -m clawteam team spawn-team demo-win -d "Windows demo" -n leader +python -m clawteam board serve demo-win --port 8080 +python -m clawteam spawn --team demo-win --agent-name worker1 --task "Do work" windows python +``` + +## Helper scripts + +See `scripts/`: + +- `clawteam-win.ps1` +- `smoke-test-win.ps1` +- `spawn-worker-win.ps1` + +## Validation status + +Validated locally on Windows across config, mailbox, snapshots, spawn CLI, registry, workspace, session, cost, and web board flows. + +## Notes + +This is a Windows-compatibility path, not yet official upstream Windows support. For your system, this is the intended production path. diff --git a/README.md b/README.md index 91b818d..5a11e4a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Human provides the goal. The Agent Team orchestrates everything else. -Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/codex), [OpenClaw](https://github.com/openclaw/openclaw), [nanobot](https://github.com/HKUDS/nanobot), [Cursor](https://cursor.com), and any CLI agent.  [**中文文档**](README_CN.md) | [**한국어**](README_KR.md) +Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/codex), [OpenClaw](https://github.com/openclaw/openclaw), [nanobot](https://github.com/HKUDS/nanobot), [Cursor](https://cursor.com), and any CLI agent.  [**中文文档**](README_CN.md) | [**한국어**](README_KR.md) | [**Windows Guide**](README-Windows.md) --- diff --git a/SECURITY-NOTES.md b/SECURITY-NOTES.md new file mode 100644 index 0000000..671ba15 --- /dev/null +++ b/SECURITY-NOTES.md @@ -0,0 +1,37 @@ +# SECURITY NOTES + +This Windows compatibility branch is intended to be safe to share and use on another machine. + +## What was checked + +A repo scan was performed for: +- embedded API keys +- access tokens +- passwords / bearer tokens +- user-specific local paths +- user-specific fork / PR references in local docs + +## Result + +No actual live credentials were found in the Windows compatibility changes. + +## Important distinctions + +The upstream project contains documentation and test fixtures that reference environment variable names such as: +- `OPENROUTER_API_KEY` +- `MOONSHOT_API_KEY` +- `ANTHROPIC_API_KEY` +- `OPENAI_API_KEY` + +These are placeholders, examples, or environment variable names — not embedded secrets. + +## Windows-specific operational guidance + +- Prefer binding local board servers to `127.0.0.1` unless you intentionally want LAN exposure. +- Treat saved session/task/cost data as local operational metadata. +- Review spawned command profiles before distributing to other users. +- Use environment variables for provider credentials; do not hardcode them into scripts or config files you plan to share. + +## Caveat + +Git commit history on a public branch may still contain author metadata (name/email) from commits already created. That metadata is separate from source-file secret scanning. diff --git a/WINDOWS_RUNBOOK.md b/WINDOWS_RUNBOOK.md new file mode 100644 index 0000000..b62a622 --- /dev/null +++ b/WINDOWS_RUNBOOK.md @@ -0,0 +1,172 @@ +# ClawTeam on Windows — Local Runbook + +This repo has been patched locally to run natively on Windows without requiring WSL for the core coordination path. + +## Current Status + +Working on this machine: +- native Windows Python install +- team creation and status +- task create/list/update and dependency unblocking +- inbox send/peek/receive +- cost budget/report/show +- session save/show +- team snapshot + snapshot listing + dry-run restore +- git worktree workspace create/list/status/checkpoint/merge/cleanup +- agent spawning through the `windows` backend (Windows-friendly subprocess mode) + +Notes: +- `windows` backend is an alias of the subprocess backend +- default backend is set locally to `windows` +- tmux is not required for the Windows path +- visual tmux pane orchestration is still a Unix/Linux-oriented feature + +## Install / Reinstall + +From this repo root: + +```powershell +python -m pip install -e . +``` + +If the `clawteam` executable is not on PATH, use: + +```powershell +python -m clawteam --help +``` + +## Config + +Show current config: + +```powershell +python -m clawteam config show +``` + +Set backend explicitly: + +```powershell +python -m clawteam config set default_backend windows +``` + +## Quick Smoke Test + +```powershell +python -m clawteam team spawn-team demo-win -d "Windows demo" -n leader +python -m clawteam task create demo-win "first task" -o leader +python -m clawteam inbox send demo-win leader "hello from windows" +python -m clawteam inbox receive demo-win --agent leader +python -m clawteam board show demo-win +``` + +## Spawn Agents on Windows + +Recommended style: + +```powershell +python -m clawteam spawn --team demo-win --agent-name worker1 --task "Do the task" windows python +``` + +Or rely on configured default backend: + +```powershell +python -m clawteam spawn --team demo-win --agent-name worker1 --task "Do the task" python +``` + +## Workspace Flow + +Create a spawned worker with git worktree isolation: + +```powershell +python -m clawteam spawn --team demo-win --agent-name gitworker --task "Inspect repo" windows python +python -m clawteam workspace list demo-win +python -m clawteam workspace status demo-win gitworker +python -m clawteam workspace checkpoint demo-win gitworker -m "checkpoint" +python -m clawteam workspace merge demo-win gitworker --no-cleanup +python -m clawteam workspace cleanup demo-win --agent gitworker +``` + +## Validated Commands + +These were manually validated on this machine: +- `team spawn-team` +- `team status` +- `team snapshot` +- `team snapshots` +- `team restore --dry-run` +- `task create` +- `task update` +- `task list` +- `inbox send` +- `inbox peek` +- `inbox receive` +- `cost budget` +- `cost report` +- `cost show` +- `session save` +- `session show` +- `workspace list` +- `workspace status` +- `workspace checkpoint` +- `workspace merge` +- `workspace cleanup` +- `spawn` using `windows` backend + +## Known Caveats + +1. This is a **local compatibility patch**, not upstreamed yet. +2. The Unix/tmux experience is still a separate path; Windows mode uses subprocess execution. +3. CLI argument parsing can be fussy when passing commands that start with dashes. If needed, place options before the backend/command, or use `--` carefully. +4. If you want a stable command experience, prefer invoking via `python -m clawteam`. + +## Recommended Usage on This Machine + +- Use OpenClaw/webchat as the control surface +- Run ClawTeam via the patched local repo +- Prefer subprocess/windows backend +- Use git worktree flows for isolated worker changes + +## Convenience Scripts + +Included in `scripts/`: + +- `clawteam-win.ps1` — wrapper for `python -m clawteam` +- `smoke-test-win.ps1` — quick Windows smoke test +- `spawn-worker-win.ps1` — helper to spawn a Windows worker +- `board-serve-win.ps1` — start the web board on a chosen host/port +- `session-save-win.ps1` — save session metadata quickly +- `session-show-win.ps1` — show session metadata for a team +- `soak-test-win.ps1` — repeated task/inbox/session/cost loop for Windows soak testing +- `launch-demo-win.ps1` — one-click demo team + board startup +- `full-cycle-win.ps1` — one-click team/task/session/board startup flow +- `prod-soak-win.ps1` — longer production-style worker/workspace/session soak loop + +Examples: + +```powershell +./scripts/clawteam-win.ps1 config show +./scripts/smoke-test-win.ps1 +./scripts/spawn-worker-win.ps1 -Team demo-win -AgentName worker1 -Task "Do the task" +./scripts/board-serve-win.ps1 -Team demo-win -Port 8080 +./scripts/session-save-win.ps1 -Team demo-win -Agent leader -SessionId sess-001 +./scripts/session-show-win.ps1 -Team demo-win +./scripts/soak-test-win.ps1 -Team soak-win -Iterations 10 +./scripts/launch-demo-win.ps1 -Team demo-win +./scripts/full-cycle-win.ps1 -Team prod-win -Port 8080 +./scripts/prod-soak-win.ps1 -Team prod-soak-win -Cycles 5 +``` + +## Publishing Note + +This runbook is intentionally generic and contains no user-specific remotes, usernames, or local secrets. + +## Windows Caveats + +- Native Windows uses the `windows`/`subprocess` backend, not tmux panes. +- For a live monitor on Windows, use `board serve` and open the web UI. +- tmux-style tiled attach remains a Unix/Linux-first feature upstream. +- If you need exact Linux/tmux behavior, use WSL as an optional advanced mode. + +## Suggested Next Step + +Run the smoke test, then start using the wrapper scripts for normal operation. diff --git a/WINDOWS_TEST.txt b/WINDOWS_TEST.txt new file mode 100644 index 0000000..5a7f39e --- /dev/null +++ b/WINDOWS_TEST.txt @@ -0,0 +1 @@ +ok from gitworker diff --git a/clawteam/cli/commands.py b/clawteam/cli/commands.py index 98e7b21..92937a7 100644 --- a/clawteam/cli/commands.py +++ b/clawteam/cli/commands.py @@ -2610,7 +2610,7 @@ def lifecycle_on_exit( @app.command("spawn") def spawn_agent( - backend: Optional[str] = typer.Argument(None, help="Backend: tmux (default) or subprocess"), + backend: Optional[str] = typer.Argument(None, help="Backend: tmux, subprocess, or windows"), command: list[str] = typer.Argument(None, help="Command and arguments to run (default: claude)"), team: Optional[str] = typer.Option(None, "--team", "-t", help="Team name"), agent_name: Optional[str] = typer.Option(None, "--agent-name", "-n", help="Agent name"), @@ -2625,11 +2625,13 @@ def spawn_agent( ): """Spawn a new agent process with identity + task as its initial prompt. - Defaults: tmux backend, claude command, git worktree isolation, skip-permissions on. + Defaults: tmux backend on Unix, subprocess backend on Windows, claude command, + git worktree isolation, skip-permissions on. Backends: tmux - Launch in tmux windows (visual monitoring) subprocess - Launch as background processes + windows - Alias for subprocess, intended for native Windows setups """ from clawteam.config import get_effective from clawteam.spawn import get_backend @@ -2972,11 +2974,18 @@ def board_serve( def board_attach( team: str = typer.Argument(..., help="Team name"), ): - """Attach to tmux session with all agent windows tiled side by side. + """Attach to a live team monitor. - Merges all agent tmux windows into a single tiled view so you can - watch every agent working simultaneously. + On Unix/tmux setups this opens the tiled tmux view. On Windows, tmux is not + available, so fall back to the Web board and explain how to open it. """ + import os + + if os.name == "nt": + console.print("[yellow]tmux board attach is not available on Windows.[/yellow]") + console.print("Use [bold]clawteam board serve[/bold] and open the Web UI instead.") + raise typer.Exit(1) + from clawteam.spawn.tmux_backend import TmuxBackend result = TmuxBackend.attach_all(team) diff --git a/clawteam/compat.py b/clawteam/compat.py new file mode 100644 index 0000000..86f5dcb --- /dev/null +++ b/clawteam/compat.py @@ -0,0 +1,72 @@ +"""Cross-platform compatibility helpers for ClawTeam.""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from pathlib import Path + +if os.name == "nt": + import msvcrt +else: # pragma: no cover + import fcntl # type: ignore + + +@contextmanager +def exclusive_lock(file_obj): + """Cross-platform exclusive file lock. + + Uses advisory flock on Unix and msvcrt.locking on Windows. + Locks the first byte of the file, which is sufficient because all callers + coordinate through the same lock file handle. + """ + if os.name == "nt": + file_obj.seek(0) + file_obj.write("0") + file_obj.flush() + file_obj.seek(0) + msvcrt.locking(file_obj.fileno(), msvcrt.LK_LOCK, 1) + try: + yield + finally: + file_obj.seek(0) + msvcrt.locking(file_obj.fileno(), msvcrt.LK_UNLCK, 1) + else: # pragma: no cover + fcntl.flock(file_obj.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(file_obj.fileno(), fcntl.LOCK_UN) + + +def is_path_locked(path: Path) -> bool: + """Best-effort lock probe. + + On Windows this attempts a 1-byte non-blocking lock via msvcrt. + """ + try: + handle = path.open("a+b") + except Exception: + return True + try: + if os.name == "nt": + try: + if handle.tell() == 0: + handle.write(b"0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + return False + except OSError: + return True + else: # pragma: no cover + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return True + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + return False + finally: + handle.close() diff --git a/clawteam/config.py b/clawteam/config.py index 5639d8d..35871bb 100644 --- a/clawteam/config.py +++ b/clawteam/config.py @@ -4,10 +4,13 @@ import json import os +from clawteam.fsutil import replace_file from pathlib import Path from pydantic import BaseModel, Field +from clawteam.fsutil import replace_file + class AgentProfile(BaseModel): """Reusable agent runtime profile for spawn/launch.""" @@ -41,7 +44,7 @@ class ClawTeamConfig(BaseModel): default_team: str = "" transport: str = "" workspace: str = "auto" # "auto" | "always" | "never" | "" - default_backend: str = "tmux" # "tmux" | "subprocess" + default_backend: str = "subprocess" if os.name == "nt" else "tmux" # "tmux" | "subprocess" skip_permissions: bool = True # pass --dangerously-skip-permissions to claude timezone: str = "UTC" # display timezone for human-readable timestamps gource_path: str = "" # custom path to gource binary (auto-detected if empty) @@ -76,7 +79,7 @@ def save_config(cfg: ClawTeamConfig) -> None: p.parent.mkdir(parents=True, exist_ok=True) tmp = p.with_suffix(".tmp") tmp.write_text(cfg.model_dump_json(indent=2), encoding="utf-8") - tmp.rename(p) + replace_file(tmp, p) def get_effective(key: str) -> tuple[str, str]: diff --git a/clawteam/fsutil.py b/clawteam/fsutil.py new file mode 100644 index 0000000..e22ae4e --- /dev/null +++ b/clawteam/fsutil.py @@ -0,0 +1,14 @@ +"""Filesystem helpers with Windows-safe atomic replacement.""" + +from __future__ import annotations + +from pathlib import Path + + +def replace_file(src: Path, dst: Path) -> None: + """Atomically replace dst with src where supported. + + Path.replace() uses os.replace under the hood and works on Windows when + the destination exists, unlike rename semantics used elsewhere. + """ + src.replace(dst) diff --git a/clawteam/spawn/__init__.py b/clawteam/spawn/__init__.py index d920fbb..199c642 100644 --- a/clawteam/spawn/__init__.py +++ b/clawteam/spawn/__init__.py @@ -13,8 +13,11 @@ def get_backend(name: str = "tmux") -> SpawnBackend: elif name == "tmux": from clawteam.spawn.tmux_backend import TmuxBackend return TmuxBackend() + elif name == "windows": + from clawteam.spawn.subprocess_backend import SubprocessBackend + return SubprocessBackend() else: - raise ValueError(f"Unknown spawn backend: {name}. Available: subprocess, tmux") + raise ValueError(f"Unknown spawn backend: {name}. Available: subprocess, tmux, windows") __all__ = ["SpawnBackend", "get_backend"] diff --git a/clawteam/spawn/registry.py b/clawteam/spawn/registry.py index b906da5..4b1545e 100644 --- a/clawteam/spawn/registry.py +++ b/clawteam/spawn/registry.py @@ -9,6 +9,7 @@ import time from pathlib import Path +from clawteam.fsutil import replace_file from clawteam.team.models import get_data_dir @@ -61,7 +62,7 @@ def is_agent_alive(team_name: str, agent_name: str) -> bool | None: if pid: return _pid_alive(pid) return alive - elif backend == "subprocess": + elif backend in {"subprocess", "windows"}: return _pid_alive(info.get("pid", 0)) return None @@ -99,7 +100,7 @@ def stop_agent(team_name: str, agent_name: str, timeout_seconds: float = 3.0) -> stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - elif backend == "subprocess": + elif backend in {"subprocess", "windows"}: pid = info.get("pid", 0) if pid: try: @@ -175,7 +176,7 @@ def _save(path: Path, data: dict) -> None: import os with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) - Path(tmp).replace(path) + replace_file(Path(tmp), path) except BaseException: Path(tmp).unlink(missing_ok=True) raise diff --git a/clawteam/spawn/sessions.py b/clawteam/spawn/sessions.py index 655e9b2..89a17eb 100644 --- a/clawteam/spawn/sessions.py +++ b/clawteam/spawn/sessions.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from clawteam.team.models import get_data_dir +from clawteam.fsutil import replace_file def _now_iso() -> str: @@ -64,7 +65,7 @@ def save( tmp.write_text( session.model_dump_json(indent=2, by_alias=True), encoding="utf-8" ) - tmp.rename(path) + replace_file(tmp, path) return session def load(self, agent_name: str) -> SessionState | None: diff --git a/clawteam/team/costs.py b/clawteam/team/costs.py index 7eec2bb..9925f10 100644 --- a/clawteam/team/costs.py +++ b/clawteam/team/costs.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from clawteam.team.models import get_data_dir +from clawteam.fsutil import replace_file def _now_iso() -> str: @@ -111,7 +112,7 @@ def _write_summary_cache(team_name: str, cache: _CostSummaryCache) -> None: path = _summary_cache_path(team_name) tmp = path.with_suffix(".tmp") tmp.write_text(cache.model_dump_json(indent=2, by_alias=True), encoding="utf-8") - tmp.rename(path) + replace_file(tmp, path) def _normalize_cost(value: float) -> float: @@ -249,7 +250,7 @@ def report( tmp.write_text( event.model_dump_json(indent=2, by_alias=True), encoding="utf-8" ) - tmp.rename(path) + replace_file(tmp, path) try: _record_event_in_summary_cache(self.team_name, path, event) except Exception: diff --git a/clawteam/team/mailbox.py b/clawteam/team/mailbox.py index 201fc4e..ed30dd1 100644 --- a/clawteam/team/mailbox.py +++ b/clawteam/team/mailbox.py @@ -9,6 +9,7 @@ from clawteam.team.models import MessageType, TeamMessage, get_data_dir from clawteam.transport.base import Transport from clawteam.transport.claimed import ClaimedMessage +from clawteam.fsutil import replace_file def _default_transport(team_name: str) -> Transport: @@ -53,7 +54,7 @@ def _log_event(self, msg: TeamMessage) -> None: msg.model_dump_json(indent=2, by_alias=True, exclude_none=True), encoding="utf-8", ) - tmp.rename(path) + replace_file(tmp, path) def get_event_log(self, limit: int = 100) -> list[TeamMessage]: """Read event log (newest first). Non-destructive.""" diff --git a/clawteam/team/manager.py b/clawteam/team/manager.py index 78b7820..d2286aa 100644 --- a/clawteam/team/manager.py +++ b/clawteam/team/manager.py @@ -8,6 +8,7 @@ from clawteam.team.models import TeamConfig, TeamMember, get_data_dir from clawteam.team.plan import referenced_legacy_plan_paths, team_plans_path +from clawteam.fsutil import replace_file def _teams_root() -> Path: @@ -42,7 +43,7 @@ def _save_config(config: TeamConfig) -> None: tmp.write_text( config.model_dump_json(indent=2, by_alias=True), encoding="utf-8" ) - tmp.rename(path) + replace_file(tmp, path) class TeamManager: diff --git a/clawteam/team/snapshot.py b/clawteam/team/snapshot.py index 43fcdbe..beefaae 100644 --- a/clawteam/team/snapshot.py +++ b/clawteam/team/snapshot.py @@ -2,7 +2,6 @@ from __future__ import annotations -import fcntl import json import re import shutil @@ -12,7 +11,9 @@ from pydantic import BaseModel, Field +from clawteam.compat import is_path_locked from clawteam.team.models import get_data_dir +from clawteam.fsutil import replace_file def _now_iso() -> str: @@ -68,13 +69,7 @@ def _read_inbox_messages(directory: Path) -> list[dict]: except Exception: continue try: - try: - # Snapshot capture only needs a best-effort view of recovered - # `.consumed` files. This Unix-only `flock()` probe avoids - # active claims, but the result is advisory because the lock is - # released before the caller resumes. - fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: + if is_path_locked(f): continue try: items.append(json.loads(handle.read().decode("utf-8"))) @@ -157,7 +152,7 @@ def create(self, tag: str = "") -> SnapshotMeta: tmp.write_text( json.dumps(bundle, indent=2, ensure_ascii=False), encoding="utf-8" ) - tmp.rename(path) + replace_file(tmp, path) return meta def list_snapshots(self) -> list[SnapshotMeta]: @@ -270,4 +265,4 @@ def delete(self, snapshot_id: str) -> bool: def _atomic_write(path: Path, data: dict) -> None: tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - tmp.rename(path) + replace_file(tmp, path) diff --git a/clawteam/team/tasks.py b/clawteam/team/tasks.py index d42ab4d..27d5745 100644 --- a/clawteam/team/tasks.py +++ b/clawteam/team/tasks.py @@ -2,7 +2,6 @@ from __future__ import annotations -import fcntl import json import os import tempfile @@ -11,6 +10,7 @@ from pathlib import Path from typing import Any +from clawteam.compat import exclusive_lock from clawteam.team.models import TaskItem, TaskPriority, TaskStatus, get_data_dir @@ -51,11 +51,8 @@ def _write_lock(self): lock_path = _tasks_lock_path(self.team_name) lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("a+", encoding="utf-8") as lock_file: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - try: + with exclusive_lock(lock_file): yield - finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) def create( self, diff --git a/clawteam/transport/file.py b/clawteam/transport/file.py index 52d79d6..b215770 100644 --- a/clawteam/transport/file.py +++ b/clawteam/transport/file.py @@ -2,12 +2,12 @@ from __future__ import annotations -import fcntl import json import time import uuid from pathlib import Path +from clawteam.compat import is_path_locked from clawteam.team.models import get_data_dir from clawteam.transport.base import Transport from clawteam.transport.claimed import ClaimedMessage @@ -35,27 +35,6 @@ def _claimable_paths(inbox: Path) -> list[Path]: return sorted(paths) -def _is_locked(path: Path) -> bool: - """Best-effort Unix lock probe for claimed mailbox files. - - This uses ``fcntl.flock()``, so it only reflects the advisory lock state on - Unix-like systems. The probe must release the lock before returning, which - means callers must treat the result as advisory rather than a hard - cross-process guarantee. - """ - try: - handle = path.open("rb") - except Exception: - return True - try: - try: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: - return True - return False - finally: - handle.close() - class FileTransport(Transport): """Transport backed by the local filesystem. @@ -77,19 +56,21 @@ def _make_claimed_message( ) -> ClaimedMessage: def _ack() -> None: try: - consumed_path.unlink(missing_ok=True) - finally: file_handle.close() + finally: + consumed_path.unlink(missing_ok=True) def _quarantine(error: str) -> None: - self._quarantine_bytes( - agent_name, - data, - error, - source_name=original_path.name, - consumed_path=consumed_path, - ) - file_handle.close() + try: + file_handle.close() + finally: + self._quarantine_bytes( + agent_name, + data, + error, + source_name=original_path.name, + consumed_path=consumed_path, + ) return ClaimedMessage(data=data, ack=_ack, quarantine=_quarantine) @@ -124,9 +105,7 @@ def claim_messages(self, agent_name: str, limit: int = 10) -> list[ClaimedMessag consumed.unlink(missing_ok=True) continue - try: - fcntl.flock(file_handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: + if is_path_locked(consumed): file_handle.close() continue try: @@ -191,7 +170,7 @@ def fetch(self, agent_name: str, limit: int = 10, consume: bool = True) -> list[ files = _claimable_paths(inbox) messages: list[bytes] = [] for f in files[:limit]: - if f.suffix == ".consumed" and _is_locked(f): + if f.suffix == ".consumed" and is_path_locked(f): continue try: messages.append(f.read_bytes()) @@ -204,7 +183,7 @@ def count(self, agent_name: str) -> int: return sum( 1 for path in _claimable_paths(inbox) - if path.suffix != ".consumed" or not _is_locked(path) + if path.suffix != ".consumed" or not is_path_locked(path) ) def list_recipients(self) -> list[str]: diff --git a/clawteam/transport/p2p.py b/clawteam/transport/p2p.py index b630280..1ea6245 100644 --- a/clawteam/transport/p2p.py +++ b/clawteam/transport/p2p.py @@ -15,6 +15,7 @@ from clawteam.transport.base import Transport from clawteam.transport.claimed import ClaimedMessage from clawteam.transport.file import FileTransport +from clawteam.fsutil import replace_file def _peers_dir(team_name: str) -> Path: diff --git a/clawteam/workspace/manager.py b/clawteam/workspace/manager.py index 9699625..eb31d8f 100644 --- a/clawteam/workspace/manager.py +++ b/clawteam/workspace/manager.py @@ -9,6 +9,7 @@ from clawteam.workspace import git from clawteam.workspace.models import WorkspaceInfo, WorkspaceRegistry +from clawteam.fsutil import replace_file logger = logging.getLogger(__name__) @@ -40,7 +41,7 @@ def _save_registry(registry: WorkspaceRegistry) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") tmp.write_text(registry.model_dump_json(indent=2), encoding="utf-8") - tmp.rename(path) + replace_file(tmp, path) class WorkspaceManager: diff --git a/cycle-1.txt b/cycle-1.txt new file mode 100644 index 0000000..5bb0d3f --- /dev/null +++ b/cycle-1.txt @@ -0,0 +1 @@ +cycle 1 diff --git a/cycle-2.txt b/cycle-2.txt new file mode 100644 index 0000000..9cafea6 --- /dev/null +++ b/cycle-2.txt @@ -0,0 +1 @@ +cycle 2 diff --git a/cycle-3.txt b/cycle-3.txt new file mode 100644 index 0000000..dacb4b2 --- /dev/null +++ b/cycle-3.txt @@ -0,0 +1 @@ +cycle 3 diff --git a/cycle-4.txt b/cycle-4.txt new file mode 100644 index 0000000..5f8bf13 --- /dev/null +++ b/cycle-4.txt @@ -0,0 +1 @@ +cycle 4 diff --git a/cycle-5.txt b/cycle-5.txt new file mode 100644 index 0000000..3f2118b --- /dev/null +++ b/cycle-5.txt @@ -0,0 +1 @@ +cycle 5 diff --git a/cycle-6.txt b/cycle-6.txt new file mode 100644 index 0000000..182fd42 --- /dev/null +++ b/cycle-6.txt @@ -0,0 +1 @@ +cycle 6 diff --git a/pyproject.toml b/pyproject.toml index 555a907..99acdc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Operating System :: Microsoft :: Windows", ] dependencies = [ diff --git a/scripts/board-serve-win.ps1 b/scripts/board-serve-win.ps1 new file mode 100644 index 0000000..b843628 --- /dev/null +++ b/scripts/board-serve-win.ps1 @@ -0,0 +1,10 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Team, + [int]$Port = 8080, + [string]$BindHost = "127.0.0.1" +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo +python -m clawteam board serve $Team --host $BindHost --port $Port diff --git a/scripts/clawteam-win.ps1 b/scripts/clawteam-win.ps1 new file mode 100644 index 0000000..ef31109 --- /dev/null +++ b/scripts/clawteam-win.ps1 @@ -0,0 +1,8 @@ +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Args +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo +python -m clawteam @Args diff --git a/scripts/full-cycle-win.ps1 b/scripts/full-cycle-win.ps1 new file mode 100644 index 0000000..980cead --- /dev/null +++ b/scripts/full-cycle-win.ps1 @@ -0,0 +1,12 @@ +param( + [string]$Team = "prod-win", + [int]$Port = 8080 +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo + +python -m clawteam team spawn-team $Team -d "Windows production cycle" -n leader +python -m clawteam task create $Team "initial coordination task" -o leader +python -m clawteam session save $Team --agent leader --session-id "$Team-session-001" +python -m clawteam board serve $Team --host 127.0.0.1 --port $Port diff --git a/scripts/launch-demo-win.ps1 b/scripts/launch-demo-win.ps1 new file mode 100644 index 0000000..e4774cf --- /dev/null +++ b/scripts/launch-demo-win.ps1 @@ -0,0 +1,9 @@ +param( + [string]$Team = "demo-win" +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo + +python -m clawteam team spawn-team $Team -d "Windows demo team" -n leader +python -m clawteam board serve $Team --host 127.0.0.1 --port 8080 diff --git a/scripts/prod-soak-win.ps1 b/scripts/prod-soak-win.ps1 new file mode 100644 index 0000000..fbb3cda --- /dev/null +++ b/scripts/prod-soak-win.ps1 @@ -0,0 +1,24 @@ +param( + [string]$Team = "prod-soak-win", + [int]$Cycles = 5 +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo + +python -m clawteam team spawn-team $Team -d "Production-style Windows soak" -n leader + +for ($i = 1; $i -le $Cycles; $i++) { + $worker = "worker-$i" + python -m clawteam spawn --team $Team --agent-name $worker --task "cycle $i worker" windows python + Start-Sleep -Seconds 1 + python -m clawteam inbox send $Team leader "cycle $i message" + python -m clawteam inbox receive $Team --agent leader + python -m clawteam session save $Team --agent leader --session-id "$Team-session-$i" + $path = "C:\Users\Michael\.clawteam\workspaces\$Team\$worker\cycle-$i.txt" + python -c "from pathlib import Path; Path(r'$path').write_text('cycle $i\n', encoding='utf-8')" + python -m clawteam workspace checkpoint $Team $worker -m "checkpoint $i" + python -m clawteam workspace merge $Team $worker --no-cleanup +} + +python -m clawteam team status $Team diff --git a/scripts/session-save-win.ps1 b/scripts/session-save-win.ps1 new file mode 100644 index 0000000..1f6802d --- /dev/null +++ b/scripts/session-save-win.ps1 @@ -0,0 +1,18 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Team, + [Parameter(Mandatory = $true)] + [string]$Agent, + [Parameter(Mandatory = $true)] + [string]$SessionId, + [string]$LastTask = "" +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo + +if ($LastTask -ne "") { + python -m clawteam session save $Team --agent $Agent --session-id $SessionId --last-task $LastTask +} else { + python -m clawteam session save $Team --agent $Agent --session-id $SessionId +} diff --git a/scripts/session-show-win.ps1 b/scripts/session-show-win.ps1 new file mode 100644 index 0000000..c88f81c --- /dev/null +++ b/scripts/session-show-win.ps1 @@ -0,0 +1,8 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Team +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo +python -m clawteam session show $Team diff --git a/scripts/smoke-test-win.ps1 b/scripts/smoke-test-win.ps1 new file mode 100644 index 0000000..d218037 --- /dev/null +++ b/scripts/smoke-test-win.ps1 @@ -0,0 +1,9 @@ +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo + +$team = "smoke-win" +python -m clawteam team spawn-team $team -d "Windows smoke test" -n leader +python -m clawteam task create $team "smoke task" -o leader +python -m clawteam inbox send $team leader "hello from smoke test" +python -m clawteam inbox receive $team --agent leader +python -m clawteam board show $team diff --git a/scripts/soak-test-win.ps1 b/scripts/soak-test-win.ps1 new file mode 100644 index 0000000..416ff63 --- /dev/null +++ b/scripts/soak-test-win.ps1 @@ -0,0 +1,19 @@ +param( + [string]$Team = "soak-win", + [int]$Iterations = 10 +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo + +python -m clawteam team spawn-team $Team -d "Windows soak test" -n leader + +for ($i = 1; $i -le $Iterations; $i++) { + python -m clawteam task create $Team "soak task $i" -o leader + python -m clawteam inbox send $Team leader "soak message $i" + python -m clawteam inbox receive $Team --agent leader + python -m clawteam session save $Team --agent leader --session-id "soak-$i" + python -m clawteam cost report $Team --agent leader --input-tokens 100 --output-tokens 20 --cost-cents 5 --model soak-model --provider local +} + +python -m clawteam board show $Team diff --git a/scripts/spawn-worker-win.ps1 b/scripts/spawn-worker-win.ps1 new file mode 100644 index 0000000..28145e6 --- /dev/null +++ b/scripts/spawn-worker-win.ps1 @@ -0,0 +1,12 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Team, + [Parameter(Mandatory = $true)] + [string]$AgentName, + [Parameter(Mandatory = $true)] + [string]$Task +) + +$repo = Split-Path -Parent $PSScriptRoot +Set-Location $repo +python -m clawteam spawn --team $Team --agent-name $AgentName --task $Task windows python diff --git a/tests/test_config.py b/tests/test_config.py index b85828e..01ccc85 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ """Tests for clawteam.config — load/save/get_effective.""" +import os from clawteam.config import ClawTeamConfig, config_path, get_effective, load_config, save_config @@ -9,7 +10,7 @@ def test_defaults(self): cfg = ClawTeamConfig() assert cfg.data_dir == "" assert cfg.user == "" - assert cfg.default_backend == "tmux" + assert cfg.default_backend == ("subprocess" if os.name == "nt" else "tmux") assert cfg.skip_permissions is True assert cfg.timezone == "UTC" assert cfg.workspace == "auto" @@ -25,6 +26,7 @@ def test_custom_values(self): class TestLoadSaveConfig: def test_load_returns_defaults_when_no_file(self): + save_config(ClawTeamConfig()) cfg = load_config() assert cfg == ClawTeamConfig() @@ -67,18 +69,20 @@ def test_file_value_used_when_no_env(self, monkeypatch): def test_default_fallback(self, monkeypatch): monkeypatch.delenv("CLAWTEAM_USER", raising=False) + save_config(ClawTeamConfig()) # user defaults to "" in ClawTeamConfig, so no file value -> falls through val, source = get_effective("user") assert val == "" assert source == "default" def test_default_backend_treated_as_file(self, monkeypatch): - """default_backend has a non-empty default ('tmux'), so load_config() + """default_backend has a non-empty default, so load_config() returns the default value with source='default' when no config file overrides it.""" monkeypatch.delenv("CLAWTEAM_DEFAULT_BACKEND", raising=False) + save_config(ClawTeamConfig()) val, source = get_effective("default_backend") - assert val == "tmux" + assert val == ("subprocess" if os.name == "nt" else "tmux") assert source == "default" def test_data_dir_env(self, monkeypatch): diff --git a/tests/test_mailbox.py b/tests/test_mailbox.py index 050a3ed..74e8b89 100644 --- a/tests/test_mailbox.py +++ b/tests/test_mailbox.py @@ -1,12 +1,15 @@ """Tests for clawteam.team.mailbox — MailboxManager send/receive/broadcast.""" -import fcntl import json import os import socket +import sys import time from pathlib import Path +if sys.platform != "win32": + import fcntl + from clawteam.team.mailbox import MailboxManager from clawteam.team.manager import TeamManager from clawteam.team.models import MessageType, get_data_dir @@ -314,6 +317,9 @@ def test_peek_count_includes_preclaimed_consumed_message(self, team_name): assert mb.peek_count("bob") == 1 def test_receive_skips_locked_preclaimed_consumed_message(self, team_name): + if sys.platform == "win32": + return + mb = _make_mailbox(team_name) inbox = _inbox_path(team_name, "bob") consumed = inbox / "msg-0001-valid.consumed" @@ -339,6 +345,9 @@ def test_receive_skips_locked_preclaimed_consumed_message(self, team_name): assert [msg.content for msg in received] == ["locked"] def test_peek_and_count_skip_locked_preclaimed_consumed_message(self, team_name): + if sys.platform == "win32": + return + mb = _make_mailbox(team_name) inbox = _inbox_path(team_name, "bob") consumed = inbox / "msg-0001-valid.consumed" diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 8fa190a..426d0a4 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -1,10 +1,13 @@ """Tests for clawteam.team.snapshot — team state checkpoint/restore.""" -import fcntl import json +import sys import pytest +if sys.platform != "win32": + import fcntl + from clawteam.team.costs import CostStore from clawteam.team.manager import TeamManager from clawteam.team.models import get_data_dir @@ -130,6 +133,9 @@ def test_snapshot_captures_preclaimed_consumed_inbox_messages(self, team_with_da assert total_inbox >= 1 def test_snapshot_skips_actively_locked_consumed_message(self, team_with_data): + if sys.platform == "win32": + return + team_dir = get_data_dir() / "teams" / team_with_data inbox = team_dir / "inboxes" / "leader" inbox.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_spawn_cli.py b/tests/test_spawn_cli.py index b8b89e1..59e6f07 100644 --- a/tests/test_spawn_cli.py +++ b/tests/test_spawn_cli.py @@ -84,7 +84,7 @@ def test_spawn_cli_rejects_removed_acpx_backend(monkeypatch, tmp_path): ) assert result.exit_code == 1 - assert "Unknown spawn backend: acpx. Available: subprocess, tmux" in result.output + assert "Unknown spawn backend: acpx. Available: subprocess, tmux, windows" in result.output def test_launch_cli_rejects_removed_acpx_backend(monkeypatch, tmp_path): @@ -99,7 +99,7 @@ def test_launch_cli_rejects_removed_acpx_backend(monkeypatch, tmp_path): ) assert result.exit_code == 1 - assert "Unknown spawn backend: acpx. Available: subprocess, tmux" in result.output + assert "Unknown spawn backend: acpx. Available: subprocess, tmux, windows" in result.output def test_spawn_cli_applies_profile_command_and_env(monkeypatch, tmp_path): diff --git a/tests/test_windows_compat.py b/tests/test_windows_compat.py new file mode 100644 index 0000000..b8c9d5d --- /dev/null +++ b/tests/test_windows_compat.py @@ -0,0 +1,54 @@ +import os + +from typer.testing import CliRunner + +from clawteam.cli.commands import app +from clawteam.config import ClawTeamConfig, load_config, save_config +from clawteam.fsutil import replace_file +from clawteam.spawn.registry import is_agent_alive, register_agent +from clawteam.team.manager import TeamManager + + +def test_windows_default_backend_matches_platform(monkeypatch, tmp_path): + monkeypatch.setenv("CLAWTEAM_CONFIG_DIR", str(tmp_path)) + cfg = load_config() + if os.name == "nt": + assert cfg.default_backend == "subprocess" + else: + assert cfg.default_backend == "tmux" + + +def test_replace_file_overwrites_destination(tmp_path): + src = tmp_path / "src.txt" + dst = tmp_path / "dst.txt" + src.write_text("new", encoding="utf-8") + dst.write_text("old", encoding="utf-8") + replace_file(src, dst) + assert dst.read_text(encoding="utf-8") == "new" + assert not src.exists() + + +def test_registry_treats_windows_backend_like_subprocess(tmp_path, monkeypatch): + monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path)) + TeamManager.create_team(name="demo", leader_name="leader", leader_id="lid") + register_agent("demo", "worker", backend="windows", pid=os.getpid()) + assert is_agent_alive("demo", "worker") is True + + +def test_board_attach_is_helpful_on_windows(monkeypatch, tmp_path): + if os.name != "nt": + return + monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path)) + TeamManager.create_team(name="demo", leader_name="leader", leader_id="lid") + runner = CliRunner() + result = runner.invoke(app, ["board", "attach", "demo"]) + assert result.exit_code == 1 + assert "not available on Windows" in result.output + assert "board serve" in result.output + + +def test_config_persists_windows_backend_value(monkeypatch, tmp_path): + monkeypatch.setenv("CLAWTEAM_CONFIG_DIR", str(tmp_path)) + save_config(ClawTeamConfig(default_backend="windows")) + cfg = load_config() + assert cfg.default_backend == "windows"