kiwi-code 0.0.441__tar.gz → 0.0.443__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.
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/PKG-INFO +1 -1
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/pyproject.toml +1 -1
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/__init__.py +1 -1
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_runtime/__init__.py +1 -1
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_runtime/main.py +283 -32
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/__init__.py +1 -1
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/main.py +0 -37
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/runtime_agent.py +22 -6
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/term_dashboard.py +854 -115
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/widgets.py +17 -1
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/uv.lock +1 -1
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/.gitignore +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/.python-version +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/CLAUDE.md +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/Makefile +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/README.md +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/dashboard.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/test_hello.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/__init__.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/conftest.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_term_dashboard_ui.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_tui_headless.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_worktrees.py +0 -0
|
@@ -12,6 +12,7 @@ Usage:
|
|
|
12
12
|
kiwi connect --server dev --scope full
|
|
13
13
|
kiwi connect --server dev --allow /path/to/extra/dir
|
|
14
14
|
"""
|
|
15
|
+
from collections import deque
|
|
15
16
|
from datetime import datetime
|
|
16
17
|
import argparse
|
|
17
18
|
import asyncio
|
|
@@ -24,6 +25,8 @@ import shlex
|
|
|
24
25
|
import signal
|
|
25
26
|
import subprocess
|
|
26
27
|
import sys
|
|
28
|
+
import random
|
|
29
|
+
import time
|
|
27
30
|
from pathlib import Path
|
|
28
31
|
from typing import Any
|
|
29
32
|
import shutil
|
|
@@ -3243,6 +3246,7 @@ async def run_command(
|
|
|
3243
3246
|
if not ok:
|
|
3244
3247
|
return {"stdout": "", "stderr": reason, "exit_code": 1}
|
|
3245
3248
|
|
|
3249
|
+
proc: asyncio.subprocess.Process | None = None
|
|
3246
3250
|
try:
|
|
3247
3251
|
cwd = allowed_dirs[0] if allowed_dirs else None
|
|
3248
3252
|
proc = await asyncio.create_subprocess_shell(
|
|
@@ -3258,10 +3262,33 @@ async def run_command(
|
|
|
3258
3262
|
stderr = stderr_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT_BYTES]
|
|
3259
3263
|
return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode}
|
|
3260
3264
|
except asyncio.TimeoutError:
|
|
3261
|
-
proc.
|
|
3262
|
-
|
|
3265
|
+
if proc and proc.returncode is None:
|
|
3266
|
+
try:
|
|
3267
|
+
proc.kill()
|
|
3268
|
+
except ProcessLookupError:
|
|
3269
|
+
pass
|
|
3270
|
+
try:
|
|
3271
|
+
await proc.wait()
|
|
3272
|
+
except Exception:
|
|
3273
|
+
pass
|
|
3263
3274
|
return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
|
|
3275
|
+
except asyncio.CancelledError:
|
|
3276
|
+
if proc and proc.returncode is None:
|
|
3277
|
+
try:
|
|
3278
|
+
proc.kill()
|
|
3279
|
+
except ProcessLookupError:
|
|
3280
|
+
pass
|
|
3281
|
+
try:
|
|
3282
|
+
await proc.wait()
|
|
3283
|
+
except Exception:
|
|
3284
|
+
pass
|
|
3285
|
+
raise
|
|
3264
3286
|
except Exception as e:
|
|
3287
|
+
if proc and proc.returncode is None:
|
|
3288
|
+
try:
|
|
3289
|
+
proc.kill()
|
|
3290
|
+
except Exception:
|
|
3291
|
+
pass
|
|
3265
3292
|
return {"stdout": "", "stderr": str(e), "exit_code": -1}
|
|
3266
3293
|
|
|
3267
3294
|
|
|
@@ -4199,16 +4226,152 @@ async def connect(
|
|
|
4199
4226
|
allowed_dirs: list[str] | None = None,
|
|
4200
4227
|
agent_id: str | None = None,
|
|
4201
4228
|
consumer_id: str | None = None,
|
|
4229
|
+
*,
|
|
4230
|
+
max_retries: int = 12,
|
|
4231
|
+
):
|
|
4232
|
+
ws_endpoint = f"{ws_url}/v1/terminal/ws/connect"
|
|
4233
|
+
retries = 0
|
|
4234
|
+
|
|
4235
|
+
# Shared state across reconnect attempts.
|
|
4236
|
+
pending_results: deque[dict[str, Any]] = deque()
|
|
4237
|
+
pending_lock = asyncio.Lock()
|
|
4238
|
+
pending_event = asyncio.Event()
|
|
4239
|
+
|
|
4240
|
+
command_queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(maxsize=64)
|
|
4241
|
+
cmd_state: dict[str, Any] = {"running": False, "command": None}
|
|
4242
|
+
|
|
4243
|
+
async def _command_worker() -> None:
|
|
4244
|
+
while True:
|
|
4245
|
+
request_id, command = await command_queue.get()
|
|
4246
|
+
result_msg: dict[str, Any] | None = None
|
|
4247
|
+
cmd_state["running"] = True
|
|
4248
|
+
cmd_state["command"] = command
|
|
4249
|
+
try:
|
|
4250
|
+
result = await run_command(
|
|
4251
|
+
command,
|
|
4252
|
+
mode=mode,
|
|
4253
|
+
allowed_dirs=allowed_dirs,
|
|
4254
|
+
)
|
|
4255
|
+
exit_code = 0
|
|
4256
|
+
try:
|
|
4257
|
+
exit_code = int(result.get("exit_code", 0))
|
|
4258
|
+
except Exception:
|
|
4259
|
+
exit_code = 0
|
|
4260
|
+
success = exit_code == 0
|
|
4261
|
+
status_msg = (
|
|
4262
|
+
f"{GREEN}OK{RESET}"
|
|
4263
|
+
if success
|
|
4264
|
+
else f"{RED}FAILED (exit {exit_code}){RESET}"
|
|
4265
|
+
)
|
|
4266
|
+
print_cmd_log(request_id, status_msg, success)
|
|
4267
|
+
result_msg = {
|
|
4268
|
+
"type": "result",
|
|
4269
|
+
"request_id": request_id,
|
|
4270
|
+
**result,
|
|
4271
|
+
}
|
|
4272
|
+
except asyncio.CancelledError:
|
|
4273
|
+
# Cancellation = runtime is shutting down. Let it propagate after cleanup.
|
|
4274
|
+
raise
|
|
4275
|
+
except Exception as e:
|
|
4276
|
+
print_cmd_log(request_id, f"{RED}FAILED{RESET}", False)
|
|
4277
|
+
result_msg = {
|
|
4278
|
+
"type": "result",
|
|
4279
|
+
"request_id": request_id,
|
|
4280
|
+
"stdout": "",
|
|
4281
|
+
"stderr": str(e),
|
|
4282
|
+
"exit_code": -1,
|
|
4283
|
+
}
|
|
4284
|
+
finally:
|
|
4285
|
+
cmd_state["running"] = False
|
|
4286
|
+
cmd_state["command"] = None
|
|
4287
|
+
try:
|
|
4288
|
+
command_queue.task_done()
|
|
4289
|
+
except Exception:
|
|
4290
|
+
pass
|
|
4291
|
+
if result_msg is not None:
|
|
4292
|
+
async with pending_lock:
|
|
4293
|
+
pending_results.append(result_msg)
|
|
4294
|
+
pending_event.set()
|
|
4295
|
+
|
|
4296
|
+
command_worker_task = asyncio.create_task(_command_worker())
|
|
4297
|
+
try:
|
|
4298
|
+
while True:
|
|
4299
|
+
attempt = retries + 1
|
|
4300
|
+
total_attempts = max_retries + 1
|
|
4301
|
+
print_status(
|
|
4302
|
+
"~",
|
|
4303
|
+
f"Connecting to {BOLD}{ws_endpoint}{RESET} {GREY}(attempt {attempt}/{total_attempts}){RESET}",
|
|
4304
|
+
C,
|
|
4305
|
+
)
|
|
4306
|
+
try:
|
|
4307
|
+
await _connect_once(
|
|
4308
|
+
ws_url,
|
|
4309
|
+
token,
|
|
4310
|
+
http_base_url=http_base_url,
|
|
4311
|
+
mode=mode,
|
|
4312
|
+
allowed_dirs=allowed_dirs,
|
|
4313
|
+
agent_id=agent_id,
|
|
4314
|
+
consumer_id=consumer_id,
|
|
4315
|
+
command_queue=command_queue,
|
|
4316
|
+
pending_results=pending_results,
|
|
4317
|
+
pending_lock=pending_lock,
|
|
4318
|
+
pending_event=pending_event,
|
|
4319
|
+
cmd_state=cmd_state,
|
|
4320
|
+
)
|
|
4321
|
+
return
|
|
4322
|
+
except asyncio.CancelledError:
|
|
4323
|
+
raise
|
|
4324
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
4325
|
+
print()
|
|
4326
|
+
print_status("!", f"Connection closed: {e}", YELLOW)
|
|
4327
|
+
except ConnectionRefusedError:
|
|
4328
|
+
print()
|
|
4329
|
+
print_status("x", f"Could not connect to {ws_endpoint}. Is the server running?", RED)
|
|
4330
|
+
except Exception as e:
|
|
4331
|
+
print()
|
|
4332
|
+
print_status("x", f"Error: {e}", RED)
|
|
4333
|
+
|
|
4334
|
+
if retries >= max_retries:
|
|
4335
|
+
print_status("x", f"Max reconnect attempts reached ({max_retries}).", RED)
|
|
4336
|
+
return
|
|
4337
|
+
|
|
4338
|
+
retries += 1
|
|
4339
|
+
base_delay = min(30.0, 1.5 * (2 ** (retries - 1)))
|
|
4340
|
+
delay = base_delay + random.uniform(0.0, 0.6)
|
|
4341
|
+
print_status(
|
|
4342
|
+
"~",
|
|
4343
|
+
f"Reconnecting in {delay:.1f}s... {GREY}(retry {retries}/{max_retries}){RESET}",
|
|
4344
|
+
YELLOW,
|
|
4345
|
+
)
|
|
4346
|
+
await asyncio.sleep(delay)
|
|
4347
|
+
finally:
|
|
4348
|
+
if command_worker_task and not command_worker_task.done():
|
|
4349
|
+
command_worker_task.cancel()
|
|
4350
|
+
await asyncio.gather(command_worker_task, return_exceptions=True)
|
|
4351
|
+
|
|
4352
|
+
|
|
4353
|
+
async def _connect_once(
|
|
4354
|
+
ws_url: str,
|
|
4355
|
+
token: str,
|
|
4356
|
+
http_base_url: str,
|
|
4357
|
+
mode: str = "restricted",
|
|
4358
|
+
allowed_dirs: list[str] | None = None,
|
|
4359
|
+
agent_id: str | None = None,
|
|
4360
|
+
consumer_id: str | None = None,
|
|
4361
|
+
*,
|
|
4362
|
+
command_queue: asyncio.Queue[tuple[str, str]],
|
|
4363
|
+
pending_results: deque[dict[str, Any]],
|
|
4364
|
+
pending_lock: asyncio.Lock,
|
|
4365
|
+
pending_event: asyncio.Event,
|
|
4366
|
+
cmd_state: dict[str, Any],
|
|
4202
4367
|
):
|
|
4203
|
-
"""Connect to the server WebSocket and process commands."""
|
|
4204
4368
|
ws_endpoint = f"{ws_url}/v1/terminal/ws/connect"
|
|
4205
|
-
print_status("~", f"Connecting to {BOLD}{ws_endpoint}{RESET}", C)
|
|
4206
4369
|
background_tasks: set[asyncio.Task] = set()
|
|
4207
4370
|
try:
|
|
4208
4371
|
async with websockets.connect(
|
|
4209
4372
|
ws_endpoint,
|
|
4210
4373
|
ping_interval=20,
|
|
4211
|
-
ping_timeout=
|
|
4374
|
+
ping_timeout=90,
|
|
4212
4375
|
close_timeout=10,
|
|
4213
4376
|
max_queue=64,
|
|
4214
4377
|
) as ws:
|
|
@@ -4262,34 +4425,114 @@ async def connect(
|
|
|
4262
4425
|
chunked_writes: dict[str, ChunkedWriteState] = {}
|
|
4263
4426
|
paired_msg_count = 0
|
|
4264
4427
|
fs_message_types = { "read_file","write_file","append_file","replace_in_file","stat_file","list_dir","mkdir","move_path","copy_path", "delete_path","apply_unified_diff","replace_line_range","insert_at_line","search_in_file","search_in_files","batch","file_write_start","file_write_chunk","file_write_finish","file_write_abort"}
|
|
4428
|
+
|
|
4429
|
+
hb_debug_env = os.environ.get("KIWI_RUNTIME_DEBUG_HEARTBEAT", "").strip().lower()
|
|
4430
|
+
hb_debug = hb_debug_env not in ("", "0", "false", "no")
|
|
4431
|
+
hb_log_every_s = 5.0
|
|
4432
|
+
try:
|
|
4433
|
+
hb_log_every_s = float(os.environ.get("KIWI_RUNTIME_DEBUG_HEARTBEAT_INTERVAL", "5") or "5")
|
|
4434
|
+
except Exception:
|
|
4435
|
+
hb_log_every_s = 5.0
|
|
4436
|
+
|
|
4437
|
+
hb_ws_ping_env = os.environ.get("KIWI_RUNTIME_DEBUG_HEARTBEAT_WS_PING", "").strip().lower()
|
|
4438
|
+
hb_ws_ping = hb_ws_ping_env not in ("", "0", "false", "no")
|
|
4439
|
+
hb_ws_ping_timeout_s = 5.0
|
|
4440
|
+
try:
|
|
4441
|
+
hb_ws_ping_timeout_s = float(os.environ.get("KIWI_RUNTIME_DEBUG_HEARTBEAT_WS_PING_TIMEOUT", "5") or "5")
|
|
4442
|
+
except Exception:
|
|
4443
|
+
hb_ws_ping_timeout_s = 5.0
|
|
4444
|
+
|
|
4445
|
+
heartbeat_debug_task: asyncio.Task | None = None
|
|
4446
|
+
pending_sender_task: asyncio.Task | None = None
|
|
4447
|
+
|
|
4448
|
+
async def _pending_sender_loop() -> None:
|
|
4449
|
+
while True:
|
|
4450
|
+
item: dict[str, Any] | None = None
|
|
4451
|
+
async with pending_lock:
|
|
4452
|
+
if pending_results:
|
|
4453
|
+
item = pending_results.popleft()
|
|
4454
|
+
|
|
4455
|
+
if item is None:
|
|
4456
|
+
pending_event.clear()
|
|
4457
|
+
async with pending_lock:
|
|
4458
|
+
if pending_results:
|
|
4459
|
+
continue
|
|
4460
|
+
await pending_event.wait()
|
|
4461
|
+
continue
|
|
4462
|
+
try:
|
|
4463
|
+
await ws.send(json.dumps(item))
|
|
4464
|
+
except asyncio.CancelledError:
|
|
4465
|
+
raise
|
|
4466
|
+
except Exception:
|
|
4467
|
+
async with pending_lock:
|
|
4468
|
+
pending_results.appendleft(item)
|
|
4469
|
+
pending_event.set()
|
|
4470
|
+
raise
|
|
4471
|
+
|
|
4472
|
+
pending_sender_task = asyncio.create_task(_pending_sender_loop())
|
|
4473
|
+
|
|
4474
|
+
async def _heartbeat_debug_loop() -> None:
|
|
4475
|
+
while True:
|
|
4476
|
+
await asyncio.sleep(max(0.2, hb_log_every_s))
|
|
4477
|
+
if not bool(cmd_state.get("running")):
|
|
4478
|
+
continue
|
|
4479
|
+
qsize = 0
|
|
4480
|
+
try:
|
|
4481
|
+
qsize = command_queue.qsize()
|
|
4482
|
+
except Exception:
|
|
4483
|
+
qsize = 0
|
|
4484
|
+
msg = f"Heartbeat(debug) | busy=True queue={qsize}"
|
|
4485
|
+
cmd = cmd_state.get("command")
|
|
4486
|
+
if cmd:
|
|
4487
|
+
cleaned = str(cmd).strip().replace("\n", " ")
|
|
4488
|
+
if len(cleaned) > 60:
|
|
4489
|
+
cleaned = cleaned[:57] + "..."
|
|
4490
|
+
msg += f" | cmd={cleaned}"
|
|
4491
|
+
if hb_ws_ping:
|
|
4492
|
+
try:
|
|
4493
|
+
waiter = await ws.ping()
|
|
4494
|
+
await asyncio.wait_for(waiter, timeout=hb_ws_ping_timeout_s)
|
|
4495
|
+
msg += " | ws_pong=ok"
|
|
4496
|
+
except Exception:
|
|
4497
|
+
msg += " | ws_pong=fail"
|
|
4498
|
+
print_status("·", msg, GREY)
|
|
4499
|
+
|
|
4500
|
+
if hb_debug:
|
|
4501
|
+
heartbeat_debug_task = asyncio.create_task(_heartbeat_debug_loop())
|
|
4502
|
+
|
|
4265
4503
|
try:
|
|
4266
4504
|
async for message in ws:
|
|
4267
4505
|
msg = json.loads(message)
|
|
4268
4506
|
msg_type = msg.get("type")
|
|
4269
4507
|
|
|
4508
|
+
if pending_sender_task and pending_sender_task.done():
|
|
4509
|
+
try:
|
|
4510
|
+
exc = pending_sender_task.exception()
|
|
4511
|
+
except asyncio.CancelledError:
|
|
4512
|
+
exc = None
|
|
4513
|
+
if exc:
|
|
4514
|
+
raise exc
|
|
4515
|
+
raise RuntimeError("Pending sender exited unexpectedly")
|
|
4516
|
+
|
|
4517
|
+
|
|
4270
4518
|
if msg_type == "command":
|
|
4271
4519
|
request_id = msg.get("request_id", "")
|
|
4272
4520
|
command = msg.get("command", "")
|
|
4273
4521
|
print_cmd_log(request_id, f"$ {BOLD}{command}{RESET}")
|
|
4274
4522
|
|
|
4275
|
-
|
|
4276
|
-
command
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4523
|
+
try:
|
|
4524
|
+
command_queue.put_nowait((str(request_id), str(command)))
|
|
4525
|
+
except asyncio.QueueFull:
|
|
4526
|
+
err = "Runtime command queue full; please retry."
|
|
4527
|
+
print_cmd_log(request_id, f"{RED}FAILED{RESET}", False)
|
|
4528
|
+
await ws.send(json.dumps({
|
|
4529
|
+
"type": "result",
|
|
4530
|
+
"request_id": request_id,
|
|
4531
|
+
"stdout": "",
|
|
4532
|
+
"stderr": err,
|
|
4533
|
+
"exit_code": 1,
|
|
4534
|
+
}))
|
|
4287
4535
|
|
|
4288
|
-
await ws.send(json.dumps({
|
|
4289
|
-
"type": "result",
|
|
4290
|
-
"request_id": request_id,
|
|
4291
|
-
**result,
|
|
4292
|
-
}))
|
|
4293
4536
|
|
|
4294
4537
|
elif msg_type == "interactive":
|
|
4295
4538
|
request_id = msg.get("request_id", "")
|
|
@@ -4304,7 +4547,12 @@ async def connect(
|
|
|
4304
4547
|
)
|
|
4305
4548
|
exit_code = result["exit_code"]
|
|
4306
4549
|
success = exit_code == 0
|
|
4307
|
-
|
|
4550
|
+
status_msg = (
|
|
4551
|
+
f"{GREEN}OK{RESET}"
|
|
4552
|
+
if success
|
|
4553
|
+
else f"{RED}FAILED (exit {exit_code}){RESET}"
|
|
4554
|
+
)
|
|
4555
|
+
print_cmd_log(request_id, status_msg, success)
|
|
4308
4556
|
|
|
4309
4557
|
await ws.send(json.dumps({
|
|
4310
4558
|
"type": "result",
|
|
@@ -4557,9 +4805,17 @@ async def connect(
|
|
|
4557
4805
|
elif msg_type == "ping":
|
|
4558
4806
|
await ws.send(json.dumps({"type": "pong"}))
|
|
4559
4807
|
|
|
4808
|
+
|
|
4560
4809
|
else:
|
|
4561
4810
|
print_status("?", f"Unknown message: {msg_type}", YELLOW)
|
|
4562
4811
|
finally:
|
|
4812
|
+
if heartbeat_debug_task and not heartbeat_debug_task.done():
|
|
4813
|
+
heartbeat_debug_task.cancel()
|
|
4814
|
+
await asyncio.gather(heartbeat_debug_task, return_exceptions=True)
|
|
4815
|
+
|
|
4816
|
+
if pending_sender_task and not pending_sender_task.done():
|
|
4817
|
+
pending_sender_task.cancel()
|
|
4818
|
+
await asyncio.gather(pending_sender_task, return_exceptions=True)
|
|
4563
4819
|
bg_tasks = list(background_tasks)
|
|
4564
4820
|
for task in bg_tasks:
|
|
4565
4821
|
task.cancel()
|
|
@@ -4584,15 +4840,10 @@ async def connect(
|
|
|
4584
4840
|
except Exception:
|
|
4585
4841
|
pass
|
|
4586
4842
|
|
|
4587
|
-
except
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
print()
|
|
4592
|
-
print_status("x", f"Could not connect to {ws_endpoint}. Is the server running?", RED)
|
|
4593
|
-
except Exception as e:
|
|
4594
|
-
print()
|
|
4595
|
-
print_status("x", f"Error: {e}", RED)
|
|
4843
|
+
except asyncio.CancelledError:
|
|
4844
|
+
raise
|
|
4845
|
+
except Exception:
|
|
4846
|
+
raise
|
|
4596
4847
|
|
|
4597
4848
|
|
|
4598
4849
|
def main():
|
|
@@ -929,43 +929,6 @@ class AutobotsTUI(App):
|
|
|
929
929
|
def action_quit(self) -> None:
|
|
930
930
|
"""Quit the application."""
|
|
931
931
|
self.request_quit()
|
|
932
|
-
try:
|
|
933
|
-
all_rt = list_known_runtimes()
|
|
934
|
-
except Exception:
|
|
935
|
-
all_rt = []
|
|
936
|
-
|
|
937
|
-
alive = [r for r in all_rt if r.get("alive") and r.get("pid")]
|
|
938
|
-
if not alive:
|
|
939
|
-
self._final_exit()
|
|
940
|
-
return
|
|
941
|
-
|
|
942
|
-
rows: list[RuntimeRow] = []
|
|
943
|
-
for r in alive:
|
|
944
|
-
try:
|
|
945
|
-
meta = r.get("meta") or {}
|
|
946
|
-
name = None
|
|
947
|
-
if isinstance(meta, dict):
|
|
948
|
-
name = meta.get("run_name")
|
|
949
|
-
rows.append(
|
|
950
|
-
RuntimeRow(
|
|
951
|
-
kind=str(r.get("kind")),
|
|
952
|
-
runtime_id=str(r.get("id")),
|
|
953
|
-
pid=int(r.get("pid")),
|
|
954
|
-
alive=True,
|
|
955
|
-
log_path=str(r.get("log_path")),
|
|
956
|
-
name=name if isinstance(name, str) else None,
|
|
957
|
-
kill=False,
|
|
958
|
-
)
|
|
959
|
-
)
|
|
960
|
-
except Exception:
|
|
961
|
-
continue
|
|
962
|
-
|
|
963
|
-
# Show interactive prompt; the callback will exit.
|
|
964
|
-
self.push_screen(RuntimeCleanupScreen(rows), callback=self._on_runtime_cleanup_done)
|
|
965
|
-
|
|
966
|
-
def action_quit(self) -> None:
|
|
967
|
-
"""Quit the application."""
|
|
968
|
-
self.request_quit()
|
|
969
932
|
|
|
970
933
|
|
|
971
934
|
def _run_tui(runtime_args: RuntimeConnectArgs | None = None):
|
|
@@ -242,14 +242,11 @@ def _pid_matches_create_time(pid: int, expected_create_time: float | None) -> bo
|
|
|
242
242
|
|
|
243
243
|
|
|
244
244
|
def _runtime_valid(pid: int, *, meta: dict[str, Any] | None, log_path: Path) -> bool:
|
|
245
|
-
"""Determine whether an on-disk runtime record is still usable."""
|
|
246
245
|
if not pid:
|
|
247
246
|
return False
|
|
248
247
|
if not _pid_alive(pid):
|
|
249
248
|
return False
|
|
250
|
-
|
|
251
|
-
return False
|
|
252
|
-
expected_ct = None
|
|
249
|
+
expected_ct: float | None = None
|
|
253
250
|
try:
|
|
254
251
|
if isinstance(meta, dict):
|
|
255
252
|
expected_ct = meta.get("create_time")
|
|
@@ -259,10 +256,21 @@ def _runtime_valid(pid: int, *, meta: dict[str, Any] | None, log_path: Path) ->
|
|
|
259
256
|
expected_ct = None
|
|
260
257
|
if not _pid_matches_create_time(pid, expected_ct if isinstance(expected_ct, (int, float)) else None):
|
|
261
258
|
return False
|
|
262
|
-
|
|
259
|
+
|
|
263
260
|
if _log_indicates_disconnected(log_path):
|
|
264
261
|
return False
|
|
265
|
-
|
|
262
|
+
|
|
263
|
+
if _pid_is_kiwi_runtime(pid):
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
if expected_ct is not None and isinstance(meta, dict) and meta.get("tui_managed"):
|
|
267
|
+
try:
|
|
268
|
+
exe = psutil.Process(pid).exe().lower()
|
|
269
|
+
if "python" in exe:
|
|
270
|
+
return True
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
return False
|
|
266
274
|
|
|
267
275
|
|
|
268
276
|
def _pid_alive(pid: int) -> bool:
|
|
@@ -826,6 +834,14 @@ def list_known_runtimes() -> list[dict[str, Any]]:
|
|
|
826
834
|
except Exception:
|
|
827
835
|
meta = {}
|
|
828
836
|
|
|
837
|
+
if pid is None and isinstance(meta, dict):
|
|
838
|
+
try:
|
|
839
|
+
mpid = meta.get("pid")
|
|
840
|
+
if isinstance(mpid, (int, float, str)) and str(mpid).strip():
|
|
841
|
+
pid = int(mpid)
|
|
842
|
+
except Exception:
|
|
843
|
+
pid = None
|
|
844
|
+
|
|
829
845
|
# If a pending runtime was soft-bound to a run, skip it to avoid duplicate
|
|
830
846
|
# entries in cleanup screens.
|
|
831
847
|
if kind == "pending" and meta.get("bound_run_id"):
|