cinna-cli 0.1.3__tar.gz → 0.1.4__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.
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/PKG-INFO +1 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/pyproject.toml +1 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/bootstrap.py +1 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/client.py +24 -2
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/main.py +71 -7
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/sync_session.py +72 -7
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/sync_tui.py +51 -6
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_main.py +34 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_sync_session.py +103 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/uv.lock +1 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/.github/workflows/publish.yml +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/.gitignore +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/LICENSE.md +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/README.md +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/docs/README.md +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/docs/interface.md +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/docs/mutagen_capabilities.md +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/__init__.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/auth.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/config.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/console.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/context.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/errors.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/logging.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/mcp_proxy.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/mutagen_runtime.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/sync.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/sync_ssh_shim.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/templates/CLAUDE.md.template +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/src/cinna/templates/__init__.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/__init__.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/conftest.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_auth.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_bootstrap.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_client.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_config.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_context.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_mutagen_runtime.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_sync.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.4}/tests/test_sync_ssh_shim.py +0 -0
|
@@ -272,7 +272,7 @@ def run_setup(setup_input: str, machine_name: str) -> None:
|
|
|
272
272
|
# in the shared Mutagen daemon.
|
|
273
273
|
if sync_started:
|
|
274
274
|
console.status("Live sync attached — press Ctrl-C to stop.")
|
|
275
|
-
sync_session.run_foreground(config)
|
|
275
|
+
sync_session.run_foreground(config, workspace_root)
|
|
276
276
|
console.status("Sync session terminated.")
|
|
277
277
|
finally:
|
|
278
278
|
client.close()
|
|
@@ -125,7 +125,9 @@ class PlatformClient:
|
|
|
125
125
|
|
|
126
126
|
# --- Remote exec (SSE stream) ---
|
|
127
127
|
|
|
128
|
-
def stream_exec(
|
|
128
|
+
def stream_exec(
|
|
129
|
+
self, agent_id: str, command: str, timeout: int | None = None
|
|
130
|
+
) -> Iterator[dict]:
|
|
129
131
|
"""POST /api/v1/cli/agents/{id}/exec — stream command output events.
|
|
130
132
|
|
|
131
133
|
Yields parsed event dicts. Known shapes:
|
|
@@ -137,9 +139,21 @@ class PlatformClient:
|
|
|
137
139
|
|
|
138
140
|
The caller is responsible for interpreting `done`/`interrupted` and
|
|
139
141
|
mapping to a process exit code.
|
|
142
|
+
|
|
143
|
+
``timeout`` (seconds) bounds the remote command's wall-clock run
|
|
144
|
+
time on the platform side. When omitted, the platform applies its
|
|
145
|
+
default.
|
|
140
146
|
"""
|
|
141
147
|
url = f"/api/v1/cli/agents/{agent_id}/exec"
|
|
142
|
-
payload = {"command": command}
|
|
148
|
+
payload: dict = {"command": command}
|
|
149
|
+
if timeout is not None:
|
|
150
|
+
payload["timeout"] = timeout
|
|
151
|
+
logger.info(
|
|
152
|
+
"stream_exec open: agent=%s timeout=%s cmd=%.200s",
|
|
153
|
+
agent_id,
|
|
154
|
+
timeout,
|
|
155
|
+
command,
|
|
156
|
+
)
|
|
143
157
|
with self._client.stream(
|
|
144
158
|
"POST", url, json=payload, timeout=EXEC_STREAM_TIMEOUT
|
|
145
159
|
) as response:
|
|
@@ -149,15 +163,23 @@ class PlatformClient:
|
|
|
149
163
|
self._handle_response(response)
|
|
150
164
|
return
|
|
151
165
|
|
|
166
|
+
logger.debug(
|
|
167
|
+
"stream_exec connected: agent=%s status=%s", agent_id, response.status_code
|
|
168
|
+
)
|
|
169
|
+
event_count = 0
|
|
152
170
|
for line in response.iter_lines():
|
|
153
171
|
if not line:
|
|
154
172
|
continue
|
|
155
173
|
if line.startswith("data: "):
|
|
156
174
|
data_str = line[6:]
|
|
157
175
|
try:
|
|
176
|
+
event_count += 1
|
|
158
177
|
yield json.loads(data_str)
|
|
159
178
|
except json.JSONDecodeError:
|
|
160
179
|
logger.warning("Could not parse SSE event: %s", data_str[:200])
|
|
180
|
+
logger.debug(
|
|
181
|
+
"stream_exec closed: agent=%s events=%d", agent_id, event_count
|
|
182
|
+
)
|
|
161
183
|
|
|
162
184
|
def close(self):
|
|
163
185
|
self._client.close()
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""cinna CLI — local development for Cinna Core agents."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import os
|
|
4
5
|
import platform
|
|
5
6
|
import shutil
|
|
6
7
|
import sys
|
|
8
|
+
import time
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
9
11
|
import click
|
|
@@ -21,6 +23,8 @@ from cinna.config import (
|
|
|
21
23
|
from cinna.mcp_proxy import run_mcp_proxy
|
|
22
24
|
from cinna.mutagen_runtime import ensure_mutagen_ready
|
|
23
25
|
|
|
26
|
+
logger = logging.getLogger("cinna.exec")
|
|
27
|
+
|
|
24
28
|
|
|
25
29
|
@click.group()
|
|
26
30
|
@click.version_option(version=__version__)
|
|
@@ -106,8 +110,16 @@ def set_token(setup_input: tuple[str, ...], name: str | None):
|
|
|
106
110
|
|
|
107
111
|
|
|
108
112
|
@cli.command(name="exec", context_settings={"ignore_unknown_options": True})
|
|
113
|
+
@click.option(
|
|
114
|
+
"--timeout",
|
|
115
|
+
"-t",
|
|
116
|
+
type=click.IntRange(min=1, max=86400),
|
|
117
|
+
default=1800,
|
|
118
|
+
show_default=True,
|
|
119
|
+
help="Max wall-clock seconds the remote command may run before being killed.",
|
|
120
|
+
)
|
|
109
121
|
@click.argument("command", nargs=-1, required=True)
|
|
110
|
-
def exec_cmd(command: tuple[str, ...]):
|
|
122
|
+
def exec_cmd(timeout: int, command: tuple[str, ...]):
|
|
111
123
|
"""Run a command in the remote agent environment.
|
|
112
124
|
|
|
113
125
|
Output streams back in real time via the platform. Exit code matches the
|
|
@@ -117,24 +129,46 @@ def exec_cmd(command: tuple[str, ...]):
|
|
|
117
129
|
cinna exec python scripts/main.py
|
|
118
130
|
cinna exec pip install pandas
|
|
119
131
|
cinna exec bash -c 'ls -la'
|
|
132
|
+
cinna exec --timeout 3600 python long_backfill.py
|
|
133
|
+
|
|
134
|
+
If your remote command takes its own ``--timeout`` flag, separate it
|
|
135
|
+
from cinna's option with ``--``:
|
|
136
|
+
|
|
137
|
+
cinna exec --timeout 3600 -- python tool.py --timeout 30
|
|
120
138
|
"""
|
|
121
139
|
root = find_workspace_root()
|
|
122
140
|
config = load_config(root)
|
|
123
141
|
|
|
124
|
-
exit_code = _run_remote_exec(config, " ".join(command))
|
|
142
|
+
exit_code = _run_remote_exec(config, " ".join(command), timeout=timeout)
|
|
125
143
|
sys.exit(exit_code)
|
|
126
144
|
|
|
127
145
|
|
|
128
|
-
def _run_remote_exec(config, command_str: str) -> int:
|
|
146
|
+
def _run_remote_exec(config, command_str: str, timeout: int = 1800) -> int:
|
|
129
147
|
"""Drive the /exec SSE stream and mirror events to the local terminal."""
|
|
130
148
|
exit_code = 0
|
|
149
|
+
exec_id: str | None = None
|
|
150
|
+
started_at = time.monotonic()
|
|
151
|
+
stdout_bytes = 0
|
|
152
|
+
stderr_bytes = 0
|
|
153
|
+
first_delta_at: float | None = None
|
|
154
|
+
terminal_event: str = "no-terminal-event"
|
|
155
|
+
|
|
156
|
+
logger.info(
|
|
157
|
+
"exec start: agent=%s timeout=%ds cmd=%r",
|
|
158
|
+
config.agent_id,
|
|
159
|
+
timeout,
|
|
160
|
+
command_str,
|
|
161
|
+
)
|
|
162
|
+
|
|
131
163
|
with PlatformClient(config) as client:
|
|
132
164
|
try:
|
|
133
|
-
for event in client.stream_exec(
|
|
165
|
+
for event in client.stream_exec(
|
|
166
|
+
config.agent_id, command_str, timeout=timeout
|
|
167
|
+
):
|
|
134
168
|
etype = event.get("type")
|
|
135
169
|
if etype == "exec_id":
|
|
136
|
-
|
|
137
|
-
|
|
170
|
+
exec_id = event.get("exec_id")
|
|
171
|
+
logger.debug("exec_id assigned: %s", exec_id)
|
|
138
172
|
continue
|
|
139
173
|
if etype == "tool_result_delta":
|
|
140
174
|
chunk = event.get("content", "")
|
|
@@ -142,15 +176,45 @@ def _run_remote_exec(config, command_str: str) -> int:
|
|
|
142
176
|
target = sys.stderr if stream == "stderr" else sys.stdout
|
|
143
177
|
target.write(chunk)
|
|
144
178
|
target.flush()
|
|
179
|
+
nbytes = len(chunk.encode("utf-8", errors="replace"))
|
|
180
|
+
if stream == "stderr":
|
|
181
|
+
stderr_bytes += nbytes
|
|
182
|
+
else:
|
|
183
|
+
stdout_bytes += nbytes
|
|
184
|
+
if first_delta_at is None:
|
|
185
|
+
first_delta_at = time.monotonic()
|
|
186
|
+
logger.debug(
|
|
187
|
+
"exec first output (stream=%s, %d bytes) after %.3fs",
|
|
188
|
+
stream, nbytes, first_delta_at - started_at,
|
|
189
|
+
)
|
|
145
190
|
elif etype == "done":
|
|
146
191
|
exit_code = int(event.get("exit_code", 0))
|
|
192
|
+
terminal_event = "done"
|
|
193
|
+
logger.debug("exec done event: exit_code=%s", exit_code)
|
|
147
194
|
elif etype == "interrupted":
|
|
148
195
|
exit_code = int(event.get("exit_code", 130))
|
|
196
|
+
terminal_event = "interrupted"
|
|
197
|
+
logger.info("exec interrupted by remote: exit_code=%s", exit_code)
|
|
149
198
|
elif etype == "error":
|
|
150
|
-
|
|
199
|
+
msg = event.get("content", "unknown error")
|
|
200
|
+
console.error(msg)
|
|
151
201
|
exit_code = 1
|
|
202
|
+
terminal_event = "error"
|
|
203
|
+
logger.error("exec remote error: %s", msg)
|
|
204
|
+
else:
|
|
205
|
+
logger.debug("exec unknown event type=%r: %.200s", etype, event)
|
|
152
206
|
except KeyboardInterrupt:
|
|
153
207
|
exit_code = 130
|
|
208
|
+
terminal_event = "keyboard-interrupt"
|
|
209
|
+
logger.info("exec interrupted locally (Ctrl-C)")
|
|
210
|
+
|
|
211
|
+
duration = time.monotonic() - started_at
|
|
212
|
+
logger.info(
|
|
213
|
+
"exec stop: agent=%s exec_id=%s exit_code=%s duration=%.3fs "
|
|
214
|
+
"stdout=%dB stderr=%dB terminal=%s",
|
|
215
|
+
config.agent_id, exec_id, exit_code, duration,
|
|
216
|
+
stdout_bytes, stderr_bytes, terminal_event,
|
|
217
|
+
)
|
|
154
218
|
return exit_code
|
|
155
219
|
|
|
156
220
|
|
|
@@ -12,6 +12,7 @@ import shlex
|
|
|
12
12
|
import shutil
|
|
13
13
|
import subprocess
|
|
14
14
|
import sys
|
|
15
|
+
import time
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
@@ -179,6 +180,29 @@ def _looks_like_stale_daemon_error(stderr: str) -> bool:
|
|
|
179
180
|
return any(marker in text for marker in _STALE_DAEMON_MARKERS)
|
|
180
181
|
|
|
181
182
|
|
|
183
|
+
# The backend closes the sync-stream WebSocket with code 1013 ("try again later")
|
|
184
|
+
# while it auto-activates a suspended agent environment. The shim surfaces this
|
|
185
|
+
# as a "received 1013 (try again later)" line and Mutagen reports it as a
|
|
186
|
+
# handshake EOF. Detect it so we can retry transparently instead of dumping the
|
|
187
|
+
# raw stack on the user.
|
|
188
|
+
_AGENT_ENV_WAKING_MARKERS = (
|
|
189
|
+
"received 1013",
|
|
190
|
+
"(try again later)",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _looks_like_agent_env_waking(stderr: str, stdout: str = "") -> bool:
|
|
195
|
+
text = (stderr or "") + "\n" + (stdout or "")
|
|
196
|
+
return any(marker in text for marker in _AGENT_ENV_WAKING_MARKERS)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# Two retries spaced 5s apart give the backend ~10s to finish auto-activation
|
|
200
|
+
# before we give up. Auto-activation polls for "running" status with a 120s
|
|
201
|
+
# deadline server-side, but the WS handshake fails fast — each client retry
|
|
202
|
+
# re-triggers ensure_environment_running and re-polls.
|
|
203
|
+
_WAKING_RETRY_DELAYS_SECONDS = (5, 5)
|
|
204
|
+
|
|
205
|
+
|
|
182
206
|
def _restart_daemon(config: CinnaConfig) -> None:
|
|
183
207
|
"""Bounce the Mutagen daemon so it picks up our env on next spawn.
|
|
184
208
|
|
|
@@ -278,15 +302,56 @@ def start(config: CinnaConfig, workspace_root: Path) -> SyncStatus:
|
|
|
278
302
|
str(local_path),
|
|
279
303
|
remote_url,
|
|
280
304
|
]
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
_restart_daemon(config)
|
|
305
|
+
|
|
306
|
+
stale_daemon_restarted = False
|
|
307
|
+
waking_attempt = 0 # how many "agent env waking" retries we've already burned
|
|
308
|
+
while True:
|
|
286
309
|
result = _run_mutagen(args, config, cwd=workspace_root)
|
|
287
|
-
|
|
310
|
+
if result.returncode == 0:
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
if (
|
|
314
|
+
not stale_daemon_restarted
|
|
315
|
+
and _looks_like_stale_daemon_error(result.stderr)
|
|
316
|
+
):
|
|
317
|
+
# Daemon was started before our current MUTAGEN_SSH_PATH wiring.
|
|
318
|
+
# Bounce it and retry; the second pass runs against a fresh env.
|
|
319
|
+
_restart_daemon(config)
|
|
320
|
+
stale_daemon_restarted = True
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
if _looks_like_agent_env_waking(result.stderr, result.stdout):
|
|
324
|
+
if waking_attempt < len(_WAKING_RETRY_DELAYS_SECONDS):
|
|
325
|
+
delay = _WAKING_RETRY_DELAYS_SECONDS[waking_attempt]
|
|
326
|
+
total = len(_WAKING_RETRY_DELAYS_SECONDS)
|
|
327
|
+
waking_attempt += 1
|
|
328
|
+
console.warn(
|
|
329
|
+
"Agent environment is not ready yet (waking up?). "
|
|
330
|
+
f"Retrying in {delay}s ({waking_attempt}/{total})…"
|
|
331
|
+
)
|
|
332
|
+
logger.info(
|
|
333
|
+
"Agent env not ready (1013); retry %d/%d after %ds",
|
|
334
|
+
waking_attempt, total, delay,
|
|
335
|
+
)
|
|
336
|
+
time.sleep(delay)
|
|
337
|
+
# A failed `sync create` may leave a half-registered session in
|
|
338
|
+
# the daemon. Terminate it so the retry starts from a clean
|
|
339
|
+
# slate; ignore the result since "not found" is fine.
|
|
340
|
+
_run_mutagen(
|
|
341
|
+
["sync", "terminate", session_name(config.agent_id)], config
|
|
342
|
+
)
|
|
343
|
+
continue
|
|
344
|
+
raise click.ClickException(
|
|
345
|
+
"Cannot reach the agent environment.\n"
|
|
346
|
+
"The platform reported the environment is still waking up or "
|
|
347
|
+
"unavailable after several retries.\n"
|
|
348
|
+
"Open the agent in the platform UI and confirm its environment "
|
|
349
|
+
"is running, then re-run 'cinna dev'."
|
|
350
|
+
)
|
|
351
|
+
|
|
288
352
|
raise click.ClickException(
|
|
289
|
-
|
|
353
|
+
"Failed to create Mutagen session:\n"
|
|
354
|
+
f"{result.stderr.strip() or result.stdout.strip()}"
|
|
290
355
|
)
|
|
291
356
|
|
|
292
357
|
return status(config)
|
|
@@ -17,6 +17,7 @@ the sync after the TUI closes.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
|
+
import contextlib
|
|
20
21
|
import json
|
|
21
22
|
import logging
|
|
22
23
|
import os
|
|
@@ -199,8 +200,8 @@ class SyncApp(App):
|
|
|
199
200
|
# 1/5 only do anything on the Conflicts tab with a row highlighted;
|
|
200
201
|
# the actions no-op otherwise so the user can mash them harmlessly.
|
|
201
202
|
# 1 and 5 are spaced apart on the keyboard to make a misfire unlikely.
|
|
202
|
-
Binding("1", "take_remote", "
|
|
203
|
-
Binding("5", "take_local", "
|
|
203
|
+
Binding("1", "take_remote", "take REMOTE", show=True, priority=True),
|
|
204
|
+
Binding("5", "take_local", "take LOCAL", show=True, priority=True),
|
|
204
205
|
]
|
|
205
206
|
|
|
206
207
|
# JSON state comes from a long-running `mutagen sync monitor` subprocess
|
|
@@ -289,15 +290,30 @@ class SyncApp(App):
|
|
|
289
290
|
|
|
290
291
|
async def on_unmount(self) -> None:
|
|
291
292
|
self._shutting_down = True
|
|
292
|
-
for task in (self._monitor_task, self._details_task):
|
|
293
|
-
if task is not None:
|
|
294
|
-
task.cancel()
|
|
295
293
|
if self._monitor_proc is not None and self._monitor_proc.returncode is None:
|
|
296
294
|
try:
|
|
297
295
|
self._monitor_proc.terminate()
|
|
298
296
|
except ProcessLookupError:
|
|
299
297
|
pass
|
|
300
298
|
|
|
299
|
+
# Cancel the data loops AND await them. If we return before they finish,
|
|
300
|
+
# textual closes the asyncio loop while a `mutagen` subprocess is still
|
|
301
|
+
# running in the background; when that process eventually exits, its
|
|
302
|
+
# SIGCHLD is dispatched to a closed loop and we get "Loop <...> that
|
|
303
|
+
# handles pid N is closed" on stderr. Awaiting the tasks here gives
|
|
304
|
+
# their finally-blocks a chance to fully reap.
|
|
305
|
+
pending = [t for t in (self._monitor_task, self._details_task) if t is not None]
|
|
306
|
+
for task in pending:
|
|
307
|
+
task.cancel()
|
|
308
|
+
if pending:
|
|
309
|
+
try:
|
|
310
|
+
await asyncio.wait_for(
|
|
311
|
+
asyncio.gather(*pending, return_exceptions=True),
|
|
312
|
+
timeout=2.0,
|
|
313
|
+
)
|
|
314
|
+
except asyncio.TimeoutError:
|
|
315
|
+
logger.debug("data-loop tasks did not finish cleanly within 2s")
|
|
316
|
+
|
|
301
317
|
def _disable_mouse_tracking(self) -> None:
|
|
302
318
|
"""Turn off the mouse-tracking modes textual enabled on startup.
|
|
303
319
|
|
|
@@ -361,6 +377,22 @@ class SyncApp(App):
|
|
|
361
377
|
self.query_one("#conflicts-list", OptionList).focus()
|
|
362
378
|
except Exception:
|
|
363
379
|
pass
|
|
380
|
+
# Footer reflects bindings as of the last refresh; tab changes flip
|
|
381
|
+
# whether take_remote/take_local are applicable (see check_action).
|
|
382
|
+
self.refresh_bindings()
|
|
383
|
+
|
|
384
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
385
|
+
# 1/5 only resolve conflicts, which only makes sense on that tab.
|
|
386
|
+
# Returning False hides them from the footer entirely (None would
|
|
387
|
+
# leave them grayed out, which is still noisy on the other tabs).
|
|
388
|
+
if action in ("take_remote", "take_local"):
|
|
389
|
+
try:
|
|
390
|
+
active = self.query_one(TabbedContent).active
|
|
391
|
+
except Exception:
|
|
392
|
+
return True
|
|
393
|
+
if active != "conflicts-tab":
|
|
394
|
+
return False
|
|
395
|
+
return True
|
|
364
396
|
|
|
365
397
|
# ── Data loops ────────────────────────────────────────────────────────
|
|
366
398
|
|
|
@@ -456,9 +488,22 @@ class SyncApp(App):
|
|
|
456
488
|
env=self._env,
|
|
457
489
|
start_new_session=True,
|
|
458
490
|
)
|
|
459
|
-
stdout, _ = await proc.communicate()
|
|
460
491
|
except (FileNotFoundError, OSError) as exc:
|
|
461
492
|
return f"(mutagen unavailable: {exc})"
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
stdout, _ = await proc.communicate()
|
|
496
|
+
except asyncio.CancelledError:
|
|
497
|
+
# The TUI is shutting down; reap the child before it outlives the
|
|
498
|
+
# event loop and triggers a "Loop ... is closed" SIGCHLD warning.
|
|
499
|
+
if proc.returncode is None:
|
|
500
|
+
try:
|
|
501
|
+
proc.kill()
|
|
502
|
+
except ProcessLookupError:
|
|
503
|
+
pass
|
|
504
|
+
with contextlib.suppress(Exception):
|
|
505
|
+
await proc.wait()
|
|
506
|
+
raise
|
|
462
507
|
if proc.returncode != 0:
|
|
463
508
|
return ""
|
|
464
509
|
return stdout.decode("utf-8", errors="replace").strip()
|
|
@@ -218,6 +218,40 @@ def test_exec_command(mock_load, mock_find, mock_exec, runner, workspace_root, s
|
|
|
218
218
|
mock_exec.assert_called_once_with(sample_config, "python scripts/main.py")
|
|
219
219
|
|
|
220
220
|
|
|
221
|
+
@patch("cinna.main.PlatformClient")
|
|
222
|
+
def test_run_remote_exec_logs_start_and_stop(mock_client_cls, sample_config, caplog):
|
|
223
|
+
"""`cinna exec` must emit start/stop log records — without them this whole
|
|
224
|
+
code path is invisible in cinna.log, which has previously made remote
|
|
225
|
+
failures very hard to debug.
|
|
226
|
+
"""
|
|
227
|
+
import logging
|
|
228
|
+
from cinna.main import _run_remote_exec
|
|
229
|
+
|
|
230
|
+
mock_client = mock_client_cls.return_value.__enter__.return_value
|
|
231
|
+
mock_client.stream_exec.return_value = iter([
|
|
232
|
+
{"type": "exec_id", "exec_id": "ex-1"},
|
|
233
|
+
{"type": "tool_result_delta", "content": "hi\n", "metadata": {"stream": "stdout"}},
|
|
234
|
+
{"type": "tool_result_delta", "content": "warn\n", "metadata": {"stream": "stderr"}},
|
|
235
|
+
{"type": "done", "exit_code": 0},
|
|
236
|
+
])
|
|
237
|
+
|
|
238
|
+
with caplog.at_level(logging.DEBUG, logger="cinna.exec"):
|
|
239
|
+
exit_code = _run_remote_exec(sample_config, "echo hi")
|
|
240
|
+
|
|
241
|
+
assert exit_code == 0
|
|
242
|
+
messages = [r.getMessage() for r in caplog.records if r.name == "cinna.exec"]
|
|
243
|
+
assert any(m.startswith("exec start:") and "echo hi" in m for m in messages)
|
|
244
|
+
assert any("exec_id assigned: ex-1" in m for m in messages)
|
|
245
|
+
assert any(
|
|
246
|
+
m.startswith("exec stop:")
|
|
247
|
+
and "exit_code=0" in m
|
|
248
|
+
and "stdout=3B" in m
|
|
249
|
+
and "stderr=5B" in m
|
|
250
|
+
and "terminal=done" in m
|
|
251
|
+
for m in messages
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
221
255
|
@patch("cinna.main.sync_session.run_foreground")
|
|
222
256
|
@patch("cinna.main.sync_session.start")
|
|
223
257
|
@patch("cinna.main.ensure_mutagen_ready")
|
|
@@ -185,3 +185,106 @@ def test_start_retries_after_stale_daemon(mock_run, sample_config, tmp_path):
|
|
|
185
185
|
assert any(args[:2] == ["daemon", "stop"] for args in mutagen_invocations)
|
|
186
186
|
# Must have invoked sync create at least twice (retry after bounce).
|
|
187
187
|
assert sum(1 for args in mutagen_invocations if args[:2] == ["sync", "create"]) == 2
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_looks_like_agent_env_waking():
|
|
191
|
+
from cinna.sync_session import _looks_like_agent_env_waking
|
|
192
|
+
|
|
193
|
+
real_world_stderr = (
|
|
194
|
+
"Error: unable to connect to beta: unable to connect to endpoint: "
|
|
195
|
+
"unable to dial agent endpoint: unable to handshake with agent process: "
|
|
196
|
+
"unable to receive server magic number: EOF (error output: "
|
|
197
|
+
"cinna-sync-ssh: ws pump ended: received 1013 (try again later); "
|
|
198
|
+
"then sent 1013 (try again later))"
|
|
199
|
+
)
|
|
200
|
+
assert _looks_like_agent_env_waking(real_world_stderr)
|
|
201
|
+
assert _looks_like_agent_env_waking("", real_world_stderr)
|
|
202
|
+
assert not _looks_like_agent_env_waking("auth failed: 401")
|
|
203
|
+
assert not _looks_like_agent_env_waking("")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@patch("cinna.sync_session.time.sleep", lambda _s: None)
|
|
207
|
+
@patch("cinna.sync_session._run_mutagen")
|
|
208
|
+
def test_start_retries_when_agent_env_waking_then_succeeds(
|
|
209
|
+
mock_run, sample_config, tmp_path
|
|
210
|
+
):
|
|
211
|
+
"""When the backend closes the WS with 1013 ('try again later') because the
|
|
212
|
+
agent env is auto-activating, the CLI must retry a couple of times before
|
|
213
|
+
surfacing an error."""
|
|
214
|
+
from cinna.sync_session import start
|
|
215
|
+
|
|
216
|
+
(tmp_path / "workspace").mkdir()
|
|
217
|
+
waking_err = (
|
|
218
|
+
"Error: unable to handshake with agent process: "
|
|
219
|
+
"unable to receive server magic number: EOF (error output: "
|
|
220
|
+
"cinna-sync-ssh: ws pump ended: received 1013 (try again later))"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
create_calls = {"n": 0}
|
|
224
|
+
|
|
225
|
+
def fake_run(args, *_a, **_kw):
|
|
226
|
+
first = args[0]
|
|
227
|
+
second = args[1] if len(args) > 1 else ""
|
|
228
|
+
if first == "daemon":
|
|
229
|
+
return MagicMock(returncode=0, stdout="", stderr="")
|
|
230
|
+
if first == "sync" and second == "list":
|
|
231
|
+
return MagicMock(returncode=0, stdout="[]", stderr="")
|
|
232
|
+
if first == "sync" and second == "terminate":
|
|
233
|
+
return MagicMock(returncode=0, stdout="", stderr="")
|
|
234
|
+
if first == "sync" and second == "create":
|
|
235
|
+
create_calls["n"] += 1
|
|
236
|
+
# First create fails with 1013; second one succeeds.
|
|
237
|
+
if create_calls["n"] == 1:
|
|
238
|
+
return MagicMock(returncode=1, stdout="", stderr=waking_err)
|
|
239
|
+
return MagicMock(returncode=0, stdout="", stderr="")
|
|
240
|
+
return MagicMock(returncode=0, stdout="", stderr="")
|
|
241
|
+
|
|
242
|
+
mock_run.side_effect = fake_run
|
|
243
|
+
start(sample_config, tmp_path)
|
|
244
|
+
|
|
245
|
+
assert create_calls["n"] == 2
|
|
246
|
+
# Between attempts we terminate the half-registered session.
|
|
247
|
+
invocations = [c.args[0] for c in mock_run.call_args_list]
|
|
248
|
+
assert any(args[:2] == ["sync", "terminate"] for args in invocations)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@patch("cinna.sync_session.time.sleep", lambda _s: None)
|
|
252
|
+
@patch("cinna.sync_session._run_mutagen")
|
|
253
|
+
def test_start_gives_up_with_friendly_error_after_max_waking_retries(
|
|
254
|
+
mock_run, sample_config, tmp_path
|
|
255
|
+
):
|
|
256
|
+
"""After exhausting the waking-env retries, the user sees a friendly
|
|
257
|
+
message instead of the raw Mutagen handshake-EOF stack."""
|
|
258
|
+
import click
|
|
259
|
+
import pytest
|
|
260
|
+
|
|
261
|
+
from cinna.sync_session import start
|
|
262
|
+
|
|
263
|
+
(tmp_path / "workspace").mkdir()
|
|
264
|
+
waking_err = (
|
|
265
|
+
"unable to handshake with agent process: received 1013 (try again later)"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def fake_run(args, *_a, **_kw):
|
|
269
|
+
first = args[0]
|
|
270
|
+
second = args[1] if len(args) > 1 else ""
|
|
271
|
+
if first == "daemon":
|
|
272
|
+
return MagicMock(returncode=0, stdout="", stderr="")
|
|
273
|
+
if first == "sync" and second == "list":
|
|
274
|
+
return MagicMock(returncode=0, stdout="[]", stderr="")
|
|
275
|
+
if first == "sync" and second == "terminate":
|
|
276
|
+
return MagicMock(returncode=0, stdout="", stderr="")
|
|
277
|
+
if first == "sync" and second == "create":
|
|
278
|
+
return MagicMock(returncode=1, stdout="", stderr=waking_err)
|
|
279
|
+
return MagicMock(returncode=0, stdout="", stderr="")
|
|
280
|
+
|
|
281
|
+
mock_run.side_effect = fake_run
|
|
282
|
+
with pytest.raises(click.ClickException) as exc_info:
|
|
283
|
+
start(sample_config, tmp_path)
|
|
284
|
+
|
|
285
|
+
msg = exc_info.value.format_message()
|
|
286
|
+
assert "Cannot reach the agent environment" in msg
|
|
287
|
+
# The raw Mutagen "magic number" / 1013 detail must not be in the surfaced
|
|
288
|
+
# error — that's the noise we're hiding from the user.
|
|
289
|
+
assert "1013" not in msg
|
|
290
|
+
assert "magic number" not in msg
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|