Skip to content

Commit 4133df3

Browse files
authored
Merge pull request ChesterRa#41 from dweb-channel/main
perf: optimize group switching and fix blob 404 errors
2 parents a731165 + 61056d4 commit 4133df3

29 files changed

Lines changed: 2040 additions & 1083 deletions

README.ja.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ cccc
6969
>
7070
> エラーが発生した場合は、診断と解決を手伝ってください。
7171
72+
### 旧バージョンからのアップグレード
73+
74+
古いバージョンの cccc-pair(例:0.3.x)がインストールされている場合は、先にアンインストールが必要です:
75+
76+
```bash
77+
# pipx ユーザー
78+
pipx uninstall cccc-pair
79+
80+
# pip ユーザー
81+
pip uninstall cccc-pair
82+
83+
# 残留ファイルがある場合は手動で削除
84+
rm -f ~/.local/bin/cccc ~/.local/bin/ccccd
85+
```
86+
87+
> **注意**:0.4.x のコマンド構造は 0.3.x と完全に異なります。旧版の `init``run``bridge` コマンドは `attach``daemon``mcp` などに置き換えられました。
88+
7289
### TestPyPI からインストール(推奨)
7390

7491
```bash

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ Copy this prompt to your AI assistant (Claude, ChatGPT, etc.):
6969
>
7070
> If you encounter any errors, please help me diagnose and resolve them.
7171
72+
### Upgrading from older versions
73+
74+
If you have an older version of cccc-pair installed (e.g., 0.3.x), you must uninstall it first:
75+
76+
```bash
77+
# For pipx users
78+
pipx uninstall cccc-pair
79+
80+
# For pip users
81+
pip uninstall cccc-pair
82+
83+
# Remove any leftover binaries if needed
84+
rm -f ~/.local/bin/cccc ~/.local/bin/ccccd
85+
```
86+
87+
> **Note**: Version 0.4.x has a completely different command structure from 0.3.x. The old `init`, `run`, `bridge` commands are replaced with `attach`, `daemon`, `mcp`, etc.
88+
7289
### From TestPyPI (recommended)
7390

7491
```bash

README.zh-CN.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ cccc
6969
>
7070
> 如果遇到任何错误,请帮我诊断并解决。
7171
72+
### 从旧版本升级
73+
74+
如果你已安装旧版本的 cccc-pair(如 0.3.x),必须先卸载:
75+
76+
```bash
77+
# pipx 用户
78+
pipx uninstall cccc-pair
79+
80+
# pip 用户
81+
pip uninstall cccc-pair
82+
83+
# 如有残留,手动删除
84+
rm -f ~/.local/bin/cccc ~/.local/bin/ccccd
85+
```
86+
87+
> **注意**:0.4.x 版本的命令结构与 0.3.x 完全不同。旧版的 `init``run``bridge` 命令已被 `attach``daemon``mcp` 等替代。
88+
7289
### 从 TestPyPI 安装(推荐)
7390

7491
```bash

docs/guide/faq.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,31 @@ Frequently asked questions about CCCC.
1010
# From TestPyPI (RC version)
1111
python -m pip install --index-url https://pypi.org/simple \
1212
--extra-index-url https://test.pypi.org/simple \
13-
cccc-pair==0.4.0rc16
13+
cccc-pair==0.4.0rc17
1414

1515
# From source
16-
git clone https://github.com/ChesterRa/cccc
16+
git clone https://github.com/dweb-channel/cccc
1717
cd cccc
1818
pip install -e .
1919
```
2020

21+
### How do I upgrade from an older version (0.3.x)?
22+
23+
You must uninstall the old version first:
24+
25+
```bash
26+
# For pipx users
27+
pipx uninstall cccc-pair
28+
29+
# For pip users
30+
pip uninstall cccc-pair
31+
32+
# Remove any leftover binaries
33+
rm -f ~/.local/bin/cccc ~/.local/bin/ccccd
34+
```
35+
36+
Then install the new version. Note that 0.4.x has a completely different command structure from 0.3.x.
37+
2138
### What are the system requirements?
2239

2340
- Python 3.9+

docs/guide/getting-started/index.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,37 @@ Both approaches require:
4141

4242
## Installation
4343

44+
### Upgrading from older versions
45+
46+
If you have an older version of cccc-pair installed (e.g., 0.3.x), you must uninstall it first:
47+
48+
```bash
49+
# For pipx users
50+
pipx uninstall cccc-pair
51+
52+
# For pip users
53+
pip uninstall cccc-pair
54+
55+
# Remove any leftover binaries if needed
56+
rm -f ~/.local/bin/cccc ~/.local/bin/ccccd
57+
```
58+
59+
::: warning Version 0.4.x Breaking Changes
60+
Version 0.4.x has a completely different command structure from 0.3.x. The old `init`, `run`, `bridge` commands are replaced with `attach`, `daemon`, `mcp`, etc.
61+
:::
62+
4463
### From TestPyPI (recommended for RC)
4564

4665
```bash
4766
python -m pip install --index-url https://pypi.org/simple \
4867
--extra-index-url https://test.pypi.org/simple \
49-
cccc-pair==0.4.0rc16
68+
cccc-pair==0.4.0rc17
5069
```
5170

5271
### From Source
5372

5473
```bash
55-
git clone https://github.com/ChesterRa/cccc
74+
git clone https://github.com/dweb-channel/cccc
5675
cd cccc
5776
pip install -e .
5877
```

docs/vnext/RELEASE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,5 @@ The release workflow is tag-driven (`v*`) and enforces that the git tag matches
4040
```bash
4141
python -m pip install --index-url https://pypi.org/simple \
4242
--extra-index-url https://test.pypi.org/simple \
43-
cccc-pair==0.4.0rc16
43+
cccc-pair==0.4.0rc17
4444
```

src/cccc/cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import argparse
44
import json
55
import os
6+
import socket
67
import subprocess
78
import sys
89
import time
@@ -465,8 +466,25 @@ def _stop_daemon() -> None:
465466
)
466467
server = uvicorn.Server(config)
467468

469+
# Get LAN IP for display
470+
def _get_lan_ip() -> str:
471+
try:
472+
# Create a socket to an external address to determine the local IP
473+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
474+
s.settimeout(0.1)
475+
s.connect(("8.8.8.8", 80))
476+
ip = s.getsockname()[0]
477+
s.close()
478+
return ip
479+
except Exception:
480+
return ""
481+
468482
try:
469483
print("[cccc] Starting web server...", file=sys.stderr)
484+
print(f"[cccc] Local: http://{host}:{port}", file=sys.stderr)
485+
lan_ip = _get_lan_ip()
486+
if lan_ip and lan_ip != host and lan_ip != "127.0.0.1":
487+
print(f"[cccc] Network: http://{lan_ip}:{port}", file=sys.stderr)
470488
server.run()
471489
except (SystemExit, KeyboardInterrupt):
472490
pass

src/cccc/daemon/automation.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,14 @@ def _base_dt(ts: str) -> Optional[datetime]:
519519
_queue_notify_to_pty(group, actor_id=aid, runner_kind=runner_kind, ev=ev, notify=notify_data)
520520

521521
def _check_actor_idle(self, group: Group, cfg: AutomationConfig, now: datetime) -> None:
522-
"""Check for idle actors and notify foreman."""
522+
"""Check for idle actors and notify foreman.
523+
524+
Idle detection uses multiple signals:
525+
1. PTY output activity (for pty runners) - most accurate for CLI agents
526+
2. Ledger activity (last event by actor) - fallback for headless runners
527+
528+
An actor is considered idle only if BOTH signals indicate inactivity.
529+
"""
523530
if cfg.actor_idle_timeout_seconds <= 0:
524531
return
525532

@@ -552,12 +559,31 @@ def _check_actor_idle(self, group: Group, cfg: AutomationConfig, now: datetime)
552559
if not pty_runner.SUPERVISOR.actor_running(group.group_id, aid):
553560
continue
554561

555-
# Get last activity
562+
# Get idle time from PTY (if applicable) - this is the most accurate signal
563+
# for CLI-based agents that produce terminal output while working
564+
pty_idle_seconds: Optional[float] = None
565+
if runner_kind != "headless":
566+
pty_idle_seconds = pty_runner.SUPERVISOR.idle_seconds(
567+
group_id=group.group_id, actor_id=aid
568+
)
569+
570+
# Get last activity from ledger (fallback)
556571
last_activity = _get_last_actor_activity(group, aid)
557-
if last_activity is None:
558-
continue # No activity yet, skip
559-
560-
idle_seconds = (now - last_activity).total_seconds()
572+
ledger_idle_seconds: Optional[float] = None
573+
if last_activity is not None:
574+
ledger_idle_seconds = (now - last_activity).total_seconds()
575+
576+
# Determine effective idle time:
577+
# - For PTY actors: use PTY idle time (more accurate)
578+
# - For headless: use ledger idle time
579+
# - If PTY shows recent activity, actor is NOT idle even if ledger is old
580+
if pty_idle_seconds is not None:
581+
idle_seconds = pty_idle_seconds
582+
elif ledger_idle_seconds is not None:
583+
idle_seconds = ledger_idle_seconds
584+
else:
585+
continue # No activity data, skip
586+
561587
if idle_seconds < float(cfg.actor_idle_timeout_seconds):
562588
continue
563589

@@ -569,7 +595,7 @@ def _check_actor_idle(self, group: Group, cfg: AutomationConfig, now: datetime)
569595
# Don't notify again within the timeout period
570596
if (now - last_notify_dt).total_seconds() < float(cfg.actor_idle_timeout_seconds):
571597
continue
572-
598+
573599
st["last_idle_notify_at"] = utc_now_iso()
574600
to_notify.append((aid, idle_seconds))
575601

src/cccc/daemon/server.py

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from ..util.conv import coerce_bool
3939
from ..util.obslog import setup_root_json_logging
4040
from ..util.fs import atomic_write_json, atomic_write_text, read_json
41+
from ..util.file_lock import acquire_lockfile, release_lockfile, LockUnavailableError
4142
from ..util.time import utc_now_iso
4243
from .automation import AutomationManager
4344
from .delivery import (
@@ -965,37 +966,30 @@ def _maybe_autostart_enabled_im_bridges() -> None:
965966
pass
966967
log_path = state_dir / "im_bridge.log"
967968

968-
log_file = None
969969
try:
970-
log_file = log_path.open("a", encoding="utf-8")
971-
env = os.environ.copy()
972-
env["CCCC_HOME"] = str(home)
973-
proc = subprocess.Popen(
974-
[sys.executable, "-m", "cccc.ports.im.bridge", gid, platform],
975-
env=env,
976-
stdout=log_file,
977-
stderr=log_file,
978-
stdin=subprocess.DEVNULL,
979-
start_new_session=True,
980-
cwd=str(home),
981-
)
982-
time.sleep(0.25)
983-
rc = proc.poll()
984-
if rc is not None:
985-
logger.warning("IM bridge autostart failed for %s (platform=%s, code=%s). See log: %s", gid, platform, rc, log_path)
986-
continue
987-
try:
988-
pid_path.write_text(str(proc.pid), encoding="utf-8")
989-
except Exception:
990-
pass
970+
with log_path.open("a", encoding="utf-8") as log_file:
971+
env = os.environ.copy()
972+
env["CCCC_HOME"] = str(home)
973+
proc = subprocess.Popen(
974+
[sys.executable, "-m", "cccc.ports.im.bridge", gid, platform],
975+
env=env,
976+
stdout=log_file,
977+
stderr=log_file,
978+
stdin=subprocess.DEVNULL,
979+
start_new_session=True,
980+
cwd=str(home),
981+
)
982+
time.sleep(0.25)
983+
rc = proc.poll()
984+
if rc is not None:
985+
logger.warning("IM bridge autostart failed for %s (platform=%s, code=%s). See log: %s", gid, platform, rc, log_path)
986+
continue
987+
try:
988+
pid_path.write_text(str(proc.pid), encoding="utf-8")
989+
except Exception:
990+
pass
991991
except Exception as e:
992992
logger.warning("IM bridge autostart failed for %s (platform=%s): %s", gid, platform, e)
993-
finally:
994-
try:
995-
if log_file:
996-
log_file.close()
997-
except Exception:
998-
pass
999993

1000994

1001995
def _maybe_autostart_running_groups() -> None:
@@ -3857,6 +3851,15 @@ def serve_forever(paths: Optional[DaemonPaths] = None) -> int:
38573851
p = paths or default_paths()
38583852
p.daemon_dir.mkdir(parents=True, exist_ok=True)
38593853

3854+
# Acquire exclusive lock to prevent multiple daemon instances (race condition fix).
3855+
# The lock is held for the lifetime of the daemon process.
3856+
lock_path = p.daemon_dir / "ccccd.lock"
3857+
try:
3858+
lock_handle = acquire_lockfile(lock_path, blocking=False)
3859+
except LockUnavailableError:
3860+
# Another daemon already holds the lock
3861+
return 0
3862+
38603863
# Apply global observability settings early (logging + developer mode gating).
38613864
try:
38623865
_apply_observability_settings(p.home, get_observability_settings())
@@ -3865,6 +3868,7 @@ def serve_forever(paths: Optional[DaemonPaths] = None) -> int:
38653868

38663869
_cleanup_stale_daemon_endpoints(p)
38673870
if _is_daemon_alive(p):
3871+
release_lockfile(lock_handle)
38683872
return 0
38693873

38703874
# Cleanup stale IM bridge state from previous runs/crashes.
@@ -4231,6 +4235,13 @@ def _bootstrap_after_listen() -> None:
42314235
p.pid_path.unlink()
42324236
except Exception:
42334237
pass
4238+
4239+
# Release the daemon lock
4240+
try:
4241+
release_lockfile(lock_handle)
4242+
except Exception:
4243+
pass
4244+
42344245
return 0
42354246

42364247

src/cccc/daemon/streaming.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import logging
45
import queue
56
import socket
67
import threading
@@ -12,6 +13,8 @@
1213
from ..kernel.inbox import is_message_for_actor
1314
from ..util.time import parse_utc_iso, utc_now_iso
1415

16+
logger = logging.getLogger(__name__)
17+
1518

1619
STREAMABLE_KINDS_V1: Set[str] = {
1720
"chat.message",
@@ -178,7 +181,26 @@ def publish(self, event: Dict[str, Any]) -> None:
178181
try:
179182
sub.q.put_nowait(event)
180183
except queue.Full:
181-
self.close(sub)
184+
# Backpressure: drop oldest events instead of closing the connection
185+
dropped = 0
186+
try:
187+
# Drop up to 10% of queue capacity to make room
188+
drop_count = max(1, sub.q.maxsize // 10)
189+
for _ in range(drop_count):
190+
try:
191+
sub.q.get_nowait()
192+
dropped += 1
193+
except queue.Empty:
194+
break
195+
sub.q.put_nowait(event)
196+
except queue.Full:
197+
# Still full after dropping, give up on this event
198+
dropped += 1
199+
if dropped > 0:
200+
logger.warning(
201+
"Event stream backpressure: dropped %d event(s) for sub %s (group=%s, by=%s)",
202+
dropped, sub.sub_id, sub.group_id, sub.by
203+
)
182204

183205

184206
EVENT_BROADCASTER = EventBroadcaster()

0 commit comments

Comments
 (0)