Skip to content

Commit a29e239

Browse files
committed
fix: stabilize async completion flow and bump version
1 parent 0a64fce commit a29e239

File tree

15 files changed

+548
-179
lines changed

15 files changed

+548
-179
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## Unreleased
44

5+
## v5.2.7 (2026-03-07)
6+
7+
### 🔧 Stability Fixes
8+
9+
- **Completion Status**: Completion hook now distinguishes `completed`, `cancelled`, `failed`, and `incomplete` instead of reporting every terminal state as completed
10+
- **Cancellation Handling**: Gemini and Claude adapters now consistently honor cancellation and emit a terminal status instead of leaving requests stuck in processing
11+
- **Routing Safety**: Completion routing now keeps parent-project to subdirectory compatibility while preventing nested child sessions from hijacking parent notifications
12+
- **Codex Session Binding**: Bound Codex requests no longer drift to a newer session log in the same worktree
13+
- **askd Startup Guardrails**: `bin/ask` now respects `CCB_ASKD_AUTOSTART=0` and scrubs inherited daemon lifecycle env before spawning askd
14+
- **Claude Session Backfill**: `ccb` startup again backfills `work_dir` and `work_dir_norm` into existing `.claude-session` files
15+
- **Regression Tests**: Added focused tests for completion status handling, caller routing, autostart behavior, cancellation paths, and Codex session binding
16+
517
## v5.2.5 (2026-02-15)
618

719
### 🔧 Bug Fixes

bin/ask

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ def _maybe_start_unified_daemon() -> bool:
221221
from askd_runtime import state_file_path
222222
from askd.daemon import ping_daemon
223223

224+
autostart_raw = (os.environ.get("CCB_ASKD_AUTOSTART") or "").strip().lower()
225+
if autostart_raw in ("0", "false", "no", "off"):
226+
return False
227+
224228
# Check if already running
225229
state_file = state_file_path("askd.json")
226230
if ping_daemon(timeout_s=0.5, state_file=state_file):
@@ -247,11 +251,15 @@ def _maybe_start_unified_daemon() -> bool:
247251

248252
# Start daemon in background with platform-specific flags
249253
try:
254+
child_env = os.environ.copy()
255+
child_env.pop("CCB_PARENT_PID", None)
256+
child_env.pop("CCB_MANAGED", None)
250257
kwargs = {
251258
"stdin": subprocess.DEVNULL,
252259
"stdout": subprocess.DEVNULL,
253260
"stderr": subprocess.DEVNULL,
254261
"close_fds": True,
262+
"env": child_env,
255263
}
256264
if os.name == "nt":
257265
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
@@ -583,6 +591,15 @@ def main(argv: list[str]) -> int:
583591
return EXIT_ERROR
584592

585593
# Default async mode: background task
594+
if _use_unified_daemon():
595+
from askd_runtime import state_file_path
596+
from askd.daemon import ping_daemon
597+
598+
state_file = state_file_path("askd.json")
599+
if not ping_daemon(timeout_s=0.5, state_file=state_file) and not _maybe_start_unified_daemon():
600+
print("[ERROR] Unified askd daemon not running", file=sys.stderr)
601+
return EXIT_ERROR
602+
586603
task_id = make_task_id()
587604
log_dir = Path(tempfile.gettempdir()) / "ccb-tasks"
588605
log_dir.mkdir(parents=True, exist_ok=True)

bin/ccb-completion-hook

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ lib_dir = script_dir.parent / "lib"
3333
sys.path.insert(0, str(lib_dir))
3434

3535
from compat import read_stdin_text, setup_windows_encoding
36+
from completion_hook import (
37+
completion_status_label,
38+
completion_status_marker,
39+
default_reply_for_status,
40+
normalize_completion_status,
41+
)
3642
from session_utils import find_project_session_file
3743

3844
setup_windows_encoding()
@@ -96,11 +102,9 @@ def _path_is_same_or_parent(parent: str, child: str) -> bool:
96102
def _work_dirs_compatible(session_work_dir: str, request_work_dir: str) -> bool:
97103
if not session_work_dir or not request_work_dir:
98104
return True
99-
# Accept exact match and parent/child relationship to support calls from subdirectories.
100-
return (
101-
_path_is_same_or_parent(session_work_dir, request_work_dir)
102-
or _path_is_same_or_parent(request_work_dir, session_work_dir)
103-
)
105+
# Accept exact match and parent->child relationship to support subdirectory calls
106+
# without letting nested child projects hijack parent-project notifications.
107+
return _path_is_same_or_parent(session_work_dir, request_work_dir)
104108

105109

106110
def send_via_terminal(pane_id: str, message: str, terminal: str, session_data: dict) -> bool:
@@ -243,7 +247,37 @@ def find_ask_command() -> str | None:
243247
return None
244248

245249

246-
def send_email_reply(provider: str, reply_content: str) -> bool:
250+
def _render_terminal_message(
251+
provider_display: str,
252+
req_id: str,
253+
reply_content: str,
254+
*,
255+
output_file: str | None,
256+
status: str,
257+
) -> str:
258+
marker = completion_status_marker(status)
259+
status_label = completion_status_label(status)
260+
if output_file:
261+
return f"""CCB_REQ_ID: {req_id}
262+
263+
{marker}
264+
Provider: {provider_display}
265+
Status: {status_label}
266+
Output file: {output_file}
267+
268+
Result: {reply_content}
269+
"""
270+
return f"""CCB_REQ_ID: {req_id}
271+
272+
{marker}
273+
Provider: {provider_display}
274+
Status: {status_label}
275+
276+
Result: {reply_content}
277+
"""
278+
279+
280+
def send_email_reply(provider: str, reply_content: str, *, status: str) -> bool:
247281
"""Send reply via email when CCB_CALLER=email."""
248282
try:
249283
from mail import load_config, SmtpSender
@@ -300,8 +334,9 @@ def send_email_reply(provider: str, reply_content: str) -> bool:
300334
# Remove common reply prefixes (case insensitive)
301335
subject = re.sub(r'^(Re:\s*|RE:\s*|回复:\s*|回复:\s*)+', '', subject, flags=re.IGNORECASE)
302336

337+
status_label = completion_status_label(status)
303338
if not subject:
304-
subject = f"[CCB] {provider.capitalize()} response"
339+
subject = f"[CCB] {provider.capitalize()} {status_label.lower()}"
305340

306341
# Build references chain for threading
307342
refs = ""
@@ -313,7 +348,7 @@ def send_email_reply(provider: str, reply_content: str) -> bool:
313348
refs = msg_id
314349

315350
sender = SmtpSender(config)
316-
body = f"[{provider.capitalize()}] {reply_content}"
351+
body = f"[{provider.capitalize()} {status_label}] {reply_content}"
317352

318353
success, result = sender.send_reply(
319354
to_addr=from_addr,
@@ -361,13 +396,20 @@ def main() -> int:
361396
reply_content = read_stdin_text()
362397
if not reply_content:
363398
reply_content = args.reply or ""
399+
done_seen = env_bool("CCB_DONE_SEEN", True)
400+
status = normalize_completion_status(os.environ.get("CCB_COMPLETION_STATUS", ""), done_seen=done_seen)
401+
if not reply_content:
402+
reply_content = default_reply_for_status(status, done_seen=done_seen)
364403

365404
# Handle email caller - send reply via email
366405
if caller == "email":
367-
if send_email_reply(provider, reply_content):
406+
if send_email_reply(provider, reply_content, status=status):
368407
return 0
369408
return 1
370409

410+
if caller == "manual":
411+
return 0
412+
371413
# Terminal caller - construct notification message
372414
provider_names = {
373415
"codex": "Codex",
@@ -378,23 +420,13 @@ def main() -> int:
378420
provider_display = provider_names.get(provider, provider.capitalize())
379421
req_id = args.req_id or "unknown"
380422

381-
if output_file:
382-
message = f"""CCB_REQ_ID: {req_id}
383-
384-
[CCB_TASK_COMPLETED]
385-
Provider: {provider_display}
386-
Output file: {output_file}
387-
388-
Result: {reply_content}
389-
"""
390-
else:
391-
message = f"""CCB_REQ_ID: {req_id}
392-
393-
[CCB_TASK_COMPLETED]
394-
Provider: {provider_display}
395-
396-
Result: {reply_content}
397-
"""
423+
message = _render_terminal_message(
424+
provider_display,
425+
req_id,
426+
reply_content,
427+
output_file=output_file,
428+
status=status,
429+
)
398430

399431
# Find caller's pane_id from session file
400432
session_files = {

ccb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ backend_env = get_backend_env()
5252
if backend_env and not os.environ.get("CCB_BACKEND_ENV"):
5353
os.environ["CCB_BACKEND_ENV"] = backend_env
5454

55-
VERSION = "5.2.4"
55+
VERSION = "5.2.7"
5656
GIT_COMMIT = "c539e79"
5757
GIT_DATE = "2026-02-25"
5858

@@ -1354,6 +1354,27 @@ class AILauncher:
13541354
def _claude_session_file(self) -> Path:
13551355
return self._project_session_file(".claude-session")
13561356

1357+
def _backfill_claude_session_work_dir_fields(self) -> None:
1358+
path = self._claude_session_file()
1359+
if not path.exists():
1360+
return
1361+
data = self._read_json_file(path)
1362+
if not isinstance(data, dict) or not data:
1363+
return
1364+
changed = False
1365+
work_dir = str(self.project_root)
1366+
work_dir_norm = _normalize_path_for_match(work_dir)
1367+
if not str(data.get("work_dir") or "").strip():
1368+
data["work_dir"] = work_dir
1369+
changed = True
1370+
if not str(data.get("work_dir_norm") or "").strip():
1371+
data["work_dir_norm"] = work_dir_norm
1372+
changed = True
1373+
if not changed:
1374+
return
1375+
payload = json.dumps(data, ensure_ascii=False, indent=2)
1376+
safe_write_session(path, payload)
1377+
13571378
def _read_local_claude_session_id(self) -> str | None:
13581379
data = self._read_json_file(self._claude_session_file())
13591380
sid = data.get("claude_session_id")
@@ -3320,6 +3341,8 @@ class AILauncher:
33203341
if not self._require_project_config_dir():
33213342
return 2
33223343

3344+
self._backfill_claude_session_work_dir_fields()
3345+
33233346
if not self.providers:
33243347
print("❌ No providers configured. Define providers in ccb.config or pass them on the command line.", file=sys.stderr)
33253348
return 2

lib/askd/adapters/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class ProviderResult:
4444
fallback_scan: bool = False
4545
log_path: Optional[str] = None
4646
extra: Optional[dict] = None
47+
status: str = ""
4748

4849

4950
class QueuedTaskLike(Protocol):
@@ -118,6 +119,7 @@ def handle_exception(self, exc: Exception, task: QueuedTask) -> ProviderResult:
118119
req_id=task.req_id,
119120
session_key=f"{self.key}:unknown",
120121
done_seen=False,
122+
status="failed",
121123
)
122124

123125
def on_start(self) -> None:

lib/askd/adapters/claude.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@
1515
from askd_runtime import log_path, write_log
1616
from ccb_protocol import BEGIN_PREFIX, REQ_ID_PREFIX
1717
from claude_comm import ClaudeLogReader
18-
from completion_hook import notify_completion
18+
from completion_hook import (
19+
COMPLETION_STATUS_CANCELLED,
20+
COMPLETION_STATUS_COMPLETED,
21+
COMPLETION_STATUS_FAILED,
22+
COMPLETION_STATUS_INCOMPLETE,
23+
default_reply_for_status,
24+
notify_completion,
25+
)
1926
from laskd_registry import get_session_registry
2027
from laskd_protocol import extract_reply_for_req, is_done_text, wrap_claude_prompt
2128
from laskd_session import compute_session_key, load_project_session
@@ -502,6 +509,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult:
502509
req_id=task.req_id,
503510
session_key=session_key,
504511
done_seen=False,
512+
status=COMPLETION_STATUS_FAILED,
505513
)
506514

507515
ok, pane_or_err = session.ensure_pane()
@@ -512,6 +520,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult:
512520
req_id=task.req_id,
513521
session_key=session_key,
514522
done_seen=False,
523+
status=COMPLETION_STATUS_FAILED,
515524
)
516525
pane_id = pane_or_err
517526

@@ -523,6 +532,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult:
523532
req_id=task.req_id,
524533
session_key=session_key,
525534
done_seen=False,
535+
status=COMPLETION_STATUS_FAILED,
526536
)
527537

528538
deadline = None if float(req.timeout_s) < 0.0 else (time.time() + float(req.timeout_s))
@@ -553,20 +563,24 @@ def _finalize_result(self, result: ProviderResult, req: ProviderRequest, task: Q
553563
_write_log(f"[INFO] done provider=claude req_id={result.req_id} exit={result.exit_code}")
554564

555565
reply_for_hook = result.reply
556-
notify_done_seen = result.done_seen
566+
status = result.status or (COMPLETION_STATUS_COMPLETED if result.done_seen else COMPLETION_STATUS_INCOMPLETE)
557567
if task.cancelled:
558-
_write_log(f"[WARN] Task cancelled, sending failure completion hook: req_id={task.req_id}")
559-
notify_done_seen = False
560-
if not (reply_for_hook or "").strip():
561-
reply_for_hook = "Task cancelled or timed out before completion."
562-
563-
_write_log(f"[INFO] notify_completion caller={req.caller} done_seen={notify_done_seen} email_req_id={req.email_req_id}")
568+
_write_log(f"[WARN] Task cancelled, sending cancellation completion hook: req_id={task.req_id}")
569+
status = COMPLETION_STATUS_CANCELLED
570+
if not (reply_for_hook or "").strip():
571+
reply_for_hook = default_reply_for_status(status, done_seen=result.done_seen)
572+
573+
_write_log(
574+
f"[INFO] notify_completion caller={req.caller} status={status} "
575+
f"done_seen={result.done_seen} email_req_id={req.email_req_id}"
576+
)
564577
notify_completion(
565578
provider="claude",
566579
output_file=req.output_path,
567580
reply=reply_for_hook,
568581
req_id=result.req_id,
569-
done_seen=notify_done_seen,
582+
done_seen=result.done_seen,
583+
status=status,
570584
caller=req.caller,
571585
email_req_id=req.email_req_id,
572586
email_msg_id=req.email_msg_id,
@@ -615,6 +629,10 @@ def _wait_for_response(
615629
last_pane_check = time.time()
616630

617631
while True:
632+
if task.cancel_event and task.cancel_event.is_set():
633+
_write_log(f"[INFO] Task cancelled during wait loop: req_id={task.req_id}")
634+
break
635+
618636
if deadline is not None:
619637
remaining = deadline - time.time()
620638
if remaining <= 0:
@@ -639,6 +657,7 @@ def _wait_for_response(
639657
anchor_seen=anchor_seen,
640658
fallback_scan=fallback_scan,
641659
anchor_ms=anchor_ms,
660+
status=COMPLETION_STATUS_FAILED,
642661
)
643662
last_pane_check = time.time()
644663

@@ -686,5 +705,8 @@ def _wait_for_response(
686705
anchor_seen=anchor_seen,
687706
anchor_ms=anchor_ms,
688707
fallback_scan=fallback_scan,
708+
status=COMPLETION_STATUS_COMPLETED if done_seen else (
709+
COMPLETION_STATUS_CANCELLED if task.cancelled else COMPLETION_STATUS_INCOMPLETE
710+
),
689711
)
690712
return result

0 commit comments

Comments
 (0)