forgexa-cli 1.18.12__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.18.12
3
+ Version: 1.19.0
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.18.12"
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.18.12"
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 process produces no stdout for longer than AGENT_IDLE_TIMEOUT.
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 AGENT_IDLE_TIMEOUT
4206
- seconds the code checks for filesystem activity in workspace_path
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 BOTH stdout AND the filesystem
4210
- are idle does the process get killed. This eliminates false-positive
4211
- kills at the idle boundary.
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
- stdout_lines: list[str] = []
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
- if stream_stderr:
4237
- # Agent (e.g. Kimi Code 0.18.0+) writes real-time progress to
4238
- # stderr. Read line-by-line and forward to on_chunk so the
4239
- # user sees output immediately. Also reset the idle timer so
4240
- # the agent is not killed while actively working on stderr.
4241
- while True:
4242
- line_bytes = await proc.stderr.readline()
4243
- if not line_bytes:
4244
- break
4245
- stderr_chunks.append(line_bytes)
4246
- line = line_bytes.decode(errors="replace").rstrip("\n")
4247
- if line:
4248
- _last_activity_at[0] = time.monotonic()
4249
- stdout_lines.append(line)
4250
- if on_chunk:
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([line])
4259
+ await on_chunk(lines)
4253
4260
  except Exception:
4254
4261
  pass
4255
- else:
4256
- data = await proc.stderr.read()
4257
- stderr_chunks.append(data)
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 stdout, 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 one line with a bounded wait ─────────────────────────
4332
+ # ── Read available bytes with a bounded wait ───────────────────
4323
4333
  try:
4324
- line_bytes = await asyncio.wait_for(
4325
- proc.stdout.readline(), timeout=check_interval
4334
+ data = await asyncio.wait_for(
4335
+ proc.stdout.read(4096), timeout=check_interval
4326
4336
  )
4327
4337
  except asyncio.TimeoutError:
4328
- # readline timed out within check_interval no new output
4329
- # yet. Loop back to re-evaluate idle/absolute conditions.
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
- # Line exceeded stream buffer limit — drain remaining bulk.
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
- for chunk_line in remaining.decode(errors="replace").split("\n"):
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 line_bytes:
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
- line = line_bytes.decode(errors="replace").rstrip("\n")
4358
- stdout_lines.append(line)
4359
- if on_chunk:
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, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
4388
+ remaining, remaining_stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
4380
4389
  if remaining:
4381
- for line in remaining.decode(errors="replace").split("\n"):
4382
- if line:
4383
- stdout_lines.append(line)
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 = "\n".join(stdout_lines)
4390
- stderr = (stderr_chunks[0] if stderr_chunks else b"").decode(errors="replace")
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 idle for {exc.idle_seconds:.0f}s without output process terminated. "
4519
- "Task may require more context decomposition or a different agent."
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(status="failed", exit_code=-1, stdout="", stderr="", error=_err)
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 idle for {exc.idle_seconds:.0f}s without output process terminated. "
5318
- "Task may require more context decomposition or a different agent."
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(status="failed", exit_code=-1, stdout="", stderr="", error=_err)
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 idle for {exc.idle_seconds:.0f}s without output process terminated. "
5387
- "Task may require more context decomposition or a different agent."
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(status="failed", exit_code=-1, stdout="", stderr="", error=_err)
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
- """Reject Git changes that alter only file modes, not file contents."""
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 not mode_only_paths:
9244
- return None
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
- sample = ", ".join(mode_only_paths[:5])
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.18.12
3
+ Version: 1.19.0
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.18.12"
3
+ version = "1.19.0"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes