Skip to content
Open
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/private-public-parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
fi
- name: Verify product tree parity
run: |
python -m argus_skill.release_tools.check_repository_parity \
# Run the stdlib-only checker as a script. ``python -m argus_skill...``
# imports argus_skill/__init__.py first and therefore requires runtime
# dependencies that this lightweight parity job intentionally does not install.
python argus_skill/release_tools/check_repository_parity.py \
--private-ref HEAD \
--public-ref "$PUBLIC_REF"
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ jobs:
run: |
python -m venv /tmp/argus-wheel-smoke
/tmp/argus-wheel-smoke/bin/python -m pip install dist/*.whl
mkdir -p /tmp/argus-wheel-smoke-cwd
cd /tmp/argus-wheel-smoke-cwd
/tmp/argus-wheel-smoke/bin/argus-skill --version
/tmp/argus-wheel-smoke/bin/python - <<'PY'
from pathlib import Path
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ jobs:
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
shell: bash
run: |
python -m ruff check desktop tests/desktop
python -m pytest -q tests/desktop/test_frozen_runtime.py
Expand Down
4 changes: 3 additions & 1 deletion argus_skill/life/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@

import portalocker

from ..core.prompt_example_tasks import is_prompt_example_task
from ..core.event_catalog import EventType, canonical_event_type
from ..core.prompt_example_tasks import is_prompt_example_task
from ..planner.work_kind import DEFAULT_WORK_KIND, parse_work_kind

_BACKLOG_THREAD_LOCKS: weakref.WeakValueDictionary[str, threading.Lock] = (
Expand Down Expand Up @@ -303,6 +303,8 @@ def _read_jsonl_tail_rg(
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10.0,
)
except (OSError, subprocess.SubprocessError):
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.2",
"release_id": "0.1.2+89bb3ffa8d99facf",
"release_id": "0.1.2+9361e37d8d8dd62b",
"schema_version": 1,
"source_digest": "89bb3ffa8d99facf7cffa29a768fb2322c0f1aebf79f4d7adc5aa657b2e02015"
"source_digest": "9361e37d8d8dd62b1aa647f1c0b89704f162cb1ce3a7ae1fb2a881e0d8a107e4"
}
23 changes: 21 additions & 2 deletions argus_skill/tools/image_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,14 +348,33 @@ def _extract_image_bytes(
raise ImageToolError("image response missing b64_json or url")


def _atomic_replace(
source: Path,
destination: Path,
*,
platform_name: str | None = None,
) -> None:
"""Replace a file atomically, tolerating brief Windows sharing races."""
platform_name = os.name if platform_name is None else platform_name
attempts = 6 if platform_name == "nt" else 1
for attempt in range(attempts):
try:
os.replace(source, destination)
return
except PermissionError:
if attempt >= attempts - 1:
raise
time.sleep(0.01 * (2**attempt))


def _atomic_write(path: Path, data: bytes, *, force: bool) -> None:
if path.exists() and not force:
raise ImageToolError(f"{path} already exists; pass --force to overwrite")
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
try:
tmp.write_bytes(data)
os.replace(tmp, path)
_atomic_replace(tmp, path)
finally:
try:
tmp.unlink()
Expand All @@ -373,7 +392,7 @@ def _atomic_write_json(path: Path, data: dict[str, Any], *, force: bool = True)
json.dumps(data, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(tmp, path)
_atomic_replace(tmp, path)
finally:
try:
tmp.unlink()
Expand Down
111 changes: 78 additions & 33 deletions argus_skill/webapi/routes/workspace_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,54 +555,99 @@ def _git(root: Path, *args: str, max_bytes: int = 2 * 1024 * 1024) -> str:
*args,
]
process: subprocess.Popen[bytes] | None = None
selector = selectors.DefaultSelector()
selector: selectors.BaseSelector | None = None
payload = bytearray()
truncated = timed_out = False
try:
process = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=safe_env)
assert process.stdout is not None
os.set_blocking(process.stdout.fileno(), False)
selector.register(process.stdout, selectors.EVENT_READ)
deadline = time.monotonic() + 8.0
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
if os.name == "nt":
# Windows pipes cannot be registered with SelectSelector and Python
# 3.11 does not expose os.set_blocking there. Keep the same memory
# bound by reading on a daemon thread and killing Git at max_bytes.
reader_state = {"truncated": False}

def _read_stdout() -> None:
try:
while len(payload) <= max_bytes:
chunk = process.stdout.read(
min(64 * 1024, max_bytes + 1 - len(payload))
)
if not chunk:
return
payload.extend(chunk)
if len(payload) > max_bytes:
reader_state["truncated"] = True
with contextlib.suppress(Exception):
process.kill()
return
except OSError:
return

reader = threading.Thread(target=_read_stdout, daemon=True)
reader.start()
reader.join(timeout=8.0)
if reader.is_alive():
timed_out = True
process.kill()
break
for key, _mask in selector.select(timeout=min(.1, remaining)):
try:
chunk = os.read(key.fileobj.fileno(), min(64 * 1024, max_bytes + 1 - len(payload)))
except BlockingIOError:
chunk = b""
if chunk:
payload.extend(chunk)
if len(payload) > max_bytes:
truncated = True
process.kill()
break
if truncated:
break
if process.poll() is not None:
while len(payload) <= max_bytes:
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=.5)
if reader.is_alive():
with contextlib.suppress(OSError):
process.stdout.close()
reader.join(timeout=.5)
truncated = reader_state["truncated"]
else:
selector = selectors.DefaultSelector()
os.set_blocking(process.stdout.fileno(), False)
selector.register(process.stdout, selectors.EVENT_READ)
deadline = time.monotonic() + 8.0
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
timed_out = True
process.kill()
break
for key, _mask in selector.select(timeout=min(.1, remaining)):
try:
chunk = os.read(process.stdout.fileno(), min(64 * 1024, max_bytes + 1 - len(payload)))
chunk = os.read(
key.fileobj.fileno(),
min(64 * 1024, max_bytes + 1 - len(payload)),
)
except BlockingIOError:
break
if not chunk:
break
payload.extend(chunk)
truncated = len(payload) > max_bytes
break
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=.5)
chunk = b""
if chunk:
payload.extend(chunk)
if len(payload) > max_bytes:
truncated = True
process.kill()
break
if truncated:
break
if process.poll() is not None:
while len(payload) <= max_bytes:
try:
chunk = os.read(
process.stdout.fileno(),
min(64 * 1024, max_bytes + 1 - len(payload)),
)
except BlockingIOError:
break
if not chunk:
break
payload.extend(chunk)
truncated = len(payload) > max_bytes
break
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=.5)
except (OSError, subprocess.SubprocessError):
if process is not None:
with contextlib.suppress(Exception):
process.kill()
return ""
finally:
selector.close()
if selector is not None:
selector.close()
if timed_out or process is None or (process.returncode not in {0, -9} and not truncated):
return ""
text = bytes(payload[:max_bytes]).decode("utf-8", errors="replace")
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.2+89bb3ffa8d99facf";
export const RELEASE_SOURCE_DIGEST = "89bb3ffa8d99facf7cffa29a768fb2322c0f1aebf79f4d7adc5aa657b2e02015";
export const RELEASE_ID = "0.1.2+9361e37d8d8dd62b";
export const RELEASE_SOURCE_DIGEST = "9361e37d8d8dd62b1aa647f1c0b89704f162cb1ce3a7ae1fb2a881e0d8a107e4";
2 changes: 1 addition & 1 deletion frontend/tui/bundle/argus.mjs

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Loading
Loading