forgexa-cli 1.18.11__tar.gz → 1.19.0__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.18.11 → forgexa_cli-1.19.0}/PKG-INFO +1 -1
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli/daemon.py +121 -73
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/pyproject.toml +1 -1
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/README.md +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/setup.cfg +0 -0
- {forgexa_cli-1.18.11 → forgexa_cli-1.19.0}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.19.0"
|
|
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
555
555
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
556
556
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
557
557
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
558
|
-
DAEMON_VERSION = "1.
|
|
558
|
+
DAEMON_VERSION = "1.19.0"
|
|
559
559
|
|
|
560
560
|
|
|
561
561
|
def _detect_client_type() -> str:
|
|
@@ -3520,16 +3520,20 @@ def _kill_proc(proc: asyncio.subprocess.Process) -> None:
|
|
|
3520
3520
|
|
|
3521
3521
|
|
|
3522
3522
|
class _IdleTimeoutError(asyncio.TimeoutError):
|
|
3523
|
-
"""Raised when an agent
|
|
3523
|
+
"""Raised when an agent produces no observable activity for AGENT_IDLE_TIMEOUT.
|
|
3524
3524
|
|
|
3525
3525
|
Subclasses asyncio.TimeoutError so existing ``except asyncio.TimeoutError``
|
|
3526
3526
|
handlers catch it, but callers can distinguish it from an absolute wall-clock
|
|
3527
3527
|
timeout via ``isinstance(exc, _IdleTimeoutError)`` or ``exc.idle_seconds``.
|
|
3528
|
+
Partial output is attached before propagation so the failure report retains
|
|
3529
|
+
the evidence needed to diagnose the blocked operation.
|
|
3528
3530
|
"""
|
|
3529
3531
|
|
|
3530
3532
|
def __init__(self, idle_seconds: float) -> None:
|
|
3531
3533
|
super().__init__(f"idle:{idle_seconds:.0f}s")
|
|
3532
3534
|
self.idle_seconds = idle_seconds
|
|
3535
|
+
self.stdout = ""
|
|
3536
|
+
self.stderr = ""
|
|
3533
3537
|
|
|
3534
3538
|
|
|
3535
3539
|
def _workspace_has_recent_activity(
|
|
@@ -4202,13 +4206,15 @@ class ProcessManager:
|
|
|
4202
4206
|
not killed while actively producing output.
|
|
4203
4207
|
- on_chunk(lines) is called with each decoded line so the caller can
|
|
4204
4208
|
forward to the progress reporter without waiting for completion.
|
|
4205
|
-
- Idle timeout: if the agent produces no stdout for
|
|
4206
|
-
seconds the code checks for filesystem activity in
|
|
4209
|
+
- Idle timeout: if the agent produces no stdout or stderr bytes for
|
|
4210
|
+
AGENT_IDLE_TIMEOUT seconds the code checks for filesystem activity in
|
|
4211
|
+
workspace_path
|
|
4207
4212
|
before deciding to kill. If files were recently modified the agent
|
|
4208
4213
|
is doing silent work (npm install, compilation, test runs, etc.) and
|
|
4209
|
-
the idle timer is reset. Only when
|
|
4210
|
-
|
|
4211
|
-
|
|
4214
|
+
the idle timer is reset. Only when output and the filesystem are
|
|
4215
|
+
idle does the process get killed. Reading byte chunks rather than
|
|
4216
|
+
lines is important: JSON and progress streams are not guaranteed to
|
|
4217
|
+
flush a newline while the agent is working.
|
|
4212
4218
|
- Absolute timeout (``timeout`` param): hard ceiling for zombie-process
|
|
4213
4219
|
prevention. Always kills at this boundary (no extension), but logs
|
|
4214
4220
|
filesystem activity status for post-mortem observability.
|
|
@@ -4227,38 +4233,42 @@ class ProcessManager:
|
|
|
4227
4233
|
pass
|
|
4228
4234
|
proc.stdin.close()
|
|
4229
4235
|
|
|
4230
|
-
|
|
4236
|
+
stdout_chunks: list[bytes] = []
|
|
4231
4237
|
stderr_chunks: list[bytes] = []
|
|
4232
4238
|
|
|
4233
4239
|
async def _read_stderr():
|
|
4234
4240
|
if not proc.stderr:
|
|
4235
4241
|
return
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
if
|
|
4242
|
+
pending_line = ""
|
|
4243
|
+
while True:
|
|
4244
|
+
data = await proc.stderr.read(4096)
|
|
4245
|
+
if not data:
|
|
4246
|
+
break
|
|
4247
|
+
# Stderr often contains startup, authentication, and transport
|
|
4248
|
+
# progress. It is an activity signal even when it is not shown
|
|
4249
|
+
# in the live execution panel.
|
|
4250
|
+
_last_activity_at[0] = time.monotonic()
|
|
4251
|
+
stderr_chunks.append(data)
|
|
4252
|
+
if stream_stderr:
|
|
4253
|
+
pending_line += data.decode(errors="replace")
|
|
4254
|
+
complete, separator, pending_line = pending_line.rpartition("\n")
|
|
4255
|
+
if separator and complete:
|
|
4256
|
+
lines = [line for line in complete.split("\n") if line]
|
|
4257
|
+
if lines and on_chunk:
|
|
4251
4258
|
try:
|
|
4252
|
-
await on_chunk(
|
|
4259
|
+
await on_chunk(lines)
|
|
4253
4260
|
except Exception:
|
|
4254
4261
|
pass
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4262
|
+
if stream_stderr and pending_line and on_chunk:
|
|
4263
|
+
try:
|
|
4264
|
+
await on_chunk([pending_line])
|
|
4265
|
+
except Exception:
|
|
4266
|
+
pass
|
|
4258
4267
|
|
|
4259
4268
|
async def _read_stdout():
|
|
4260
4269
|
if not proc.stdout:
|
|
4261
4270
|
return
|
|
4271
|
+
pending_line = ""
|
|
4262
4272
|
while True:
|
|
4263
4273
|
# ── Timeout checks ────────────────────────────────────────────
|
|
4264
4274
|
now = time.monotonic()
|
|
@@ -4304,7 +4314,7 @@ class ProcessManager:
|
|
|
4304
4314
|
else:
|
|
4305
4315
|
# No stdout AND no filesystem activity → truly hung.
|
|
4306
4316
|
logger.warning(
|
|
4307
|
-
"Task %s agent idle %.0fs — no
|
|
4317
|
+
"Task %s agent idle %.0fs — no output, no "
|
|
4308
4318
|
"filesystem activity; killing hung process",
|
|
4309
4319
|
task_id, idle_elapsed,
|
|
4310
4320
|
)
|
|
@@ -4319,17 +4329,17 @@ class ProcessManager:
|
|
|
4319
4329
|
30.0,
|
|
4320
4330
|
)
|
|
4321
4331
|
|
|
4322
|
-
# ── Read
|
|
4332
|
+
# ── Read available bytes with a bounded wait ───────────────────
|
|
4323
4333
|
try:
|
|
4324
|
-
|
|
4325
|
-
proc.stdout.
|
|
4334
|
+
data = await asyncio.wait_for(
|
|
4335
|
+
proc.stdout.read(4096), timeout=check_interval
|
|
4326
4336
|
)
|
|
4327
4337
|
except asyncio.TimeoutError:
|
|
4328
|
-
#
|
|
4329
|
-
#
|
|
4338
|
+
# No new output yet. Loop back to re-evaluate idle/absolute
|
|
4339
|
+
# conditions.
|
|
4330
4340
|
continue
|
|
4331
4341
|
except (ValueError, asyncio.LimitOverrunError, Exception) as exc:
|
|
4332
|
-
#
|
|
4342
|
+
# Stream read error — drain remaining bulk.
|
|
4333
4343
|
logger.warning(
|
|
4334
4344
|
"Stream read error for task %s (%s: %s), draining remaining output",
|
|
4335
4345
|
task_id, type(exc).__name__, exc,
|
|
@@ -4339,28 +4349,27 @@ class ProcessManager:
|
|
|
4339
4349
|
except Exception:
|
|
4340
4350
|
remaining = b""
|
|
4341
4351
|
if remaining:
|
|
4342
|
-
|
|
4343
|
-
if chunk_line:
|
|
4344
|
-
stdout_lines.append(chunk_line)
|
|
4345
|
-
if on_chunk:
|
|
4346
|
-
try:
|
|
4347
|
-
await on_chunk([chunk_line])
|
|
4348
|
-
except Exception:
|
|
4349
|
-
pass
|
|
4352
|
+
stdout_chunks.append(remaining)
|
|
4350
4353
|
break
|
|
4351
4354
|
|
|
4352
|
-
if not
|
|
4355
|
+
if not data:
|
|
4353
4356
|
break # EOF — process exited normally
|
|
4354
4357
|
|
|
4355
4358
|
# ── New output received — reset idle timer ────────────────────
|
|
4356
4359
|
_last_activity_at[0] = time.monotonic()
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
+
stdout_chunks.append(data)
|
|
4361
|
+
pending_line += data.decode(errors="replace")
|
|
4362
|
+
complete, separator, pending_line = pending_line.rpartition("\n")
|
|
4363
|
+
if separator and complete and on_chunk:
|
|
4360
4364
|
try:
|
|
4361
|
-
await on_chunk([line])
|
|
4365
|
+
await on_chunk([line for line in complete.split("\n") if line])
|
|
4362
4366
|
except Exception:
|
|
4363
4367
|
pass # never let on_chunk crash the agent run
|
|
4368
|
+
if pending_line and on_chunk:
|
|
4369
|
+
try:
|
|
4370
|
+
await on_chunk([pending_line])
|
|
4371
|
+
except Exception:
|
|
4372
|
+
pass
|
|
4364
4373
|
|
|
4365
4374
|
try:
|
|
4366
4375
|
# Outer wait_for uses timeout+idle_timeout as generous safety net.
|
|
@@ -4376,18 +4385,23 @@ class ProcessManager:
|
|
|
4376
4385
|
_kill_proc(proc)
|
|
4377
4386
|
# Drain any remaining output after kill
|
|
4378
4387
|
try:
|
|
4379
|
-
remaining,
|
|
4388
|
+
remaining, remaining_stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
|
|
4380
4389
|
if remaining:
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4390
|
+
stdout_chunks.append(remaining)
|
|
4391
|
+
if remaining_stderr:
|
|
4392
|
+
stderr_chunks.append(remaining_stderr)
|
|
4384
4393
|
except Exception:
|
|
4385
4394
|
pass
|
|
4395
|
+
if isinstance(_exc, _IdleTimeoutError):
|
|
4396
|
+
_exc.stdout = b"".join(stdout_chunks).decode(errors="replace")
|
|
4397
|
+
_exc.stderr = b"".join(stderr_chunks).decode(errors="replace")
|
|
4386
4398
|
raise # re-raise (_IdleTimeoutError preserves subclass type)
|
|
4387
4399
|
|
|
4388
4400
|
await proc.wait()
|
|
4389
|
-
stdout = "
|
|
4390
|
-
stderr =
|
|
4401
|
+
stdout = b"".join(stdout_chunks).decode(errors="replace")
|
|
4402
|
+
stderr = b"".join(stderr_chunks).decode(errors="replace")
|
|
4403
|
+
if stream_stderr and stderr:
|
|
4404
|
+
stdout = "\n".join(part for part in (stdout, stderr) if part)
|
|
4391
4405
|
return stdout, stderr, proc.returncode or 0
|
|
4392
4406
|
|
|
4393
4407
|
async def _run_claude(
|
|
@@ -4515,10 +4529,17 @@ class ProcessManager:
|
|
|
4515
4529
|
except asyncio.TimeoutError as exc:
|
|
4516
4530
|
_kill_proc(self.active_processes.pop(task_id, None) or proc)
|
|
4517
4531
|
_err = (
|
|
4518
|
-
f"Agent
|
|
4519
|
-
"
|
|
4532
|
+
f"Agent produced no stdout, stderr, or workspace changes for "
|
|
4533
|
+
f"{exc.idle_seconds:.0f}s — process terminated. Inspect retained "
|
|
4534
|
+
"output and the daemon log for the blocked operation."
|
|
4520
4535
|
) if isinstance(exc, _IdleTimeoutError) else f"Timed out after {timeout}s (absolute limit)"
|
|
4521
|
-
return TaskResult(
|
|
4536
|
+
return TaskResult(
|
|
4537
|
+
status="failed",
|
|
4538
|
+
exit_code=-1,
|
|
4539
|
+
stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
4540
|
+
stderr=getattr(exc, "stderr", "")[-10000:],
|
|
4541
|
+
error=_err,
|
|
4542
|
+
)
|
|
4522
4543
|
except Exception as exc:
|
|
4523
4544
|
logger.exception("Claude stream error for task %s", task_id)
|
|
4524
4545
|
if task_id in self.active_processes:
|
|
@@ -5314,10 +5335,17 @@ class ProcessManager:
|
|
|
5314
5335
|
except asyncio.TimeoutError as exc:
|
|
5315
5336
|
_kill_proc(self.active_processes.pop(task_id, None) or proc)
|
|
5316
5337
|
_err = (
|
|
5317
|
-
f"Agent
|
|
5318
|
-
"
|
|
5338
|
+
f"Agent produced no stdout, stderr, or workspace changes for "
|
|
5339
|
+
f"{exc.idle_seconds:.0f}s — process terminated. Inspect retained "
|
|
5340
|
+
"output and the daemon log for the blocked operation."
|
|
5319
5341
|
) if isinstance(exc, _IdleTimeoutError) else f"Timed out after {timeout}s (absolute limit)"
|
|
5320
|
-
return TaskResult(
|
|
5342
|
+
return TaskResult(
|
|
5343
|
+
status="failed",
|
|
5344
|
+
exit_code=-1,
|
|
5345
|
+
stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
5346
|
+
stderr=getattr(exc, "stderr", "")[-10000:],
|
|
5347
|
+
error=_err,
|
|
5348
|
+
)
|
|
5321
5349
|
except Exception as exc:
|
|
5322
5350
|
logger.exception("Copilot stream error for task %s", task_id)
|
|
5323
5351
|
if task_id in self.active_processes:
|
|
@@ -5383,10 +5411,17 @@ class ProcessManager:
|
|
|
5383
5411
|
except asyncio.TimeoutError as exc:
|
|
5384
5412
|
_kill_proc(self.active_processes.pop(task_id, None) or proc)
|
|
5385
5413
|
_err = (
|
|
5386
|
-
f"Agent
|
|
5387
|
-
"
|
|
5414
|
+
f"Agent produced no stdout, stderr, or workspace changes for "
|
|
5415
|
+
f"{exc.idle_seconds:.0f}s — process terminated. Inspect retained "
|
|
5416
|
+
"output and the daemon log for the blocked operation."
|
|
5388
5417
|
) if isinstance(exc, _IdleTimeoutError) else f"Timed out after {timeout}s (absolute limit)"
|
|
5389
|
-
return TaskResult(
|
|
5418
|
+
return TaskResult(
|
|
5419
|
+
status="failed",
|
|
5420
|
+
exit_code=-1,
|
|
5421
|
+
stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
5422
|
+
stderr=getattr(exc, "stderr", "")[-10000:],
|
|
5423
|
+
error=_err,
|
|
5424
|
+
)
|
|
5390
5425
|
except Exception as exc:
|
|
5391
5426
|
logger.exception("CLI stream error for task %s", task_id)
|
|
5392
5427
|
if task_id in self.active_processes:
|
|
@@ -9213,7 +9248,19 @@ class RuntimeDaemon:
|
|
|
9213
9248
|
workspace_path: Path,
|
|
9214
9249
|
*diff_args: str,
|
|
9215
9250
|
) -> str | None:
|
|
9216
|
-
"""
|
|
9251
|
+
"""Detect Git changes that alter only a file's mode, not its content.
|
|
9252
|
+
|
|
9253
|
+
Every Forgexa-managed workspace is configured with
|
|
9254
|
+
``core.filemode=false`` (see ``_prepare_workspace``), which makes Git
|
|
9255
|
+
ignore on-disk executable-bit drift entirely for ``git status``/
|
|
9256
|
+
``git diff``/``git add`` — incidental permission noise from
|
|
9257
|
+
checkouts, worktree creation, or file-rewrite tools can never reach
|
|
9258
|
+
the diff/index we inspect here. The ONLY way a mode-only change can
|
|
9259
|
+
appear is a deliberate ``git update-index --chmod=...`` (e.g. an
|
|
9260
|
+
agent restoring a shell script's executable bit that a prior commit
|
|
9261
|
+
lost). That is legitimate, intentional work — not noise — so it
|
|
9262
|
+
must be allowed to commit; we only log it for audit visibility.
|
|
9263
|
+
"""
|
|
9217
9264
|
proc = await asyncio.create_subprocess_exec(
|
|
9218
9265
|
"git", "diff", "--raw", "--no-abbrev", "--no-renames", "-z", *diff_args,
|
|
9219
9266
|
cwd=str(workspace_path),
|
|
@@ -9240,17 +9287,18 @@ class RuntimeDaemon:
|
|
|
9240
9287
|
if old_object == new_object and old_mode != new_mode:
|
|
9241
9288
|
mode_only_paths.append(path)
|
|
9242
9289
|
|
|
9243
|
-
if
|
|
9244
|
-
|
|
9290
|
+
if mode_only_paths:
|
|
9291
|
+
sample = ", ".join(mode_only_paths[:5])
|
|
9292
|
+
remainder = "" if len(mode_only_paths) <= 5 else ", ..."
|
|
9293
|
+
logger.info(
|
|
9294
|
+
"Auto-commit includes %d permission-only file change(s) with "
|
|
9295
|
+
"unchanged content (%s%s) — allowing (core.filemode=false means "
|
|
9296
|
+
"this can only come from a deliberate executable-bit fix, not "
|
|
9297
|
+
"incidental noise).",
|
|
9298
|
+
len(mode_only_paths), sample, remainder,
|
|
9299
|
+
)
|
|
9245
9300
|
|
|
9246
|
-
|
|
9247
|
-
remainder = "" if len(mode_only_paths) <= 5 else ", ..."
|
|
9248
|
-
return (
|
|
9249
|
-
"Auto-commit blocked: detected "
|
|
9250
|
-
f"{len(mode_only_paths)} permission-only file change(s) with unchanged content "
|
|
9251
|
-
f"({sample}{remainder}). Restore the original modes before retrying; "
|
|
9252
|
-
"Forgexa does not auto-commit permission-only changes."
|
|
9253
|
-
)
|
|
9301
|
+
return None
|
|
9254
9302
|
|
|
9255
9303
|
async def _auto_commit(
|
|
9256
9304
|
self,
|
|
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
|