cinna-cli 0.1.3__tar.gz → 0.1.5__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.5}/PKG-INFO +5 -2
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/README.md +4 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/docs/README.md +17 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/pyproject.toml +1 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/bootstrap.py +1 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/client.py +24 -2
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/main.py +78 -7
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/sync_session.py +72 -7
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/sync_tui.py +51 -6
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_main.py +63 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_sync_session.py +103 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/uv.lock +1 -1
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/.github/workflows/publish.yml +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/.gitignore +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/LICENSE.md +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/docs/interface.md +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/docs/mutagen_capabilities.md +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/__init__.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/auth.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/config.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/console.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/context.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/errors.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/logging.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/mcp_proxy.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/mutagen_runtime.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/sync.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/sync_ssh_shim.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/templates/CLAUDE.md.template +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/templates/__init__.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/__init__.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/conftest.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_auth.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_bootstrap.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_client.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_config.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_context.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_mutagen_runtime.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_sync.py +0 -0
- {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_sync_ssh_shim.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cinna-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Local development CLI for Cinna Core agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/opencinna/cinna-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/opencinna/cinna-cli
|
|
@@ -127,10 +127,13 @@ Read-only views onto the live sync session (started by `cinna dev`).
|
|
|
127
127
|
|
|
128
128
|
Stream a command through the platform to the remote agent environment. Output streams back live; Ctrl+C aborts. Exit code matches the remote process.
|
|
129
129
|
|
|
130
|
+
Arguments pass through transparently — each token is re-quoted before being sent, so spaces and shell metacharacters inside an argument survive intact. Use ordinary single-level quoting, exactly as for a local command. To run a shell snippet (pipes, redirects, `&&`), pass it to a shell explicitly: `cinna exec bash -c '…'`.
|
|
131
|
+
|
|
130
132
|
```bash
|
|
131
133
|
cinna exec python scripts/main.py
|
|
132
134
|
cinna exec pip install pandas
|
|
133
|
-
cinna exec
|
|
135
|
+
cinna exec bash -c 'ls -la'
|
|
136
|
+
cinna exec python -c 'import sys; print(sys.argv)' "a b"
|
|
134
137
|
```
|
|
135
138
|
|
|
136
139
|
### `cinna status`
|
|
@@ -90,10 +90,13 @@ Read-only views onto the live sync session (started by `cinna dev`).
|
|
|
90
90
|
|
|
91
91
|
Stream a command through the platform to the remote agent environment. Output streams back live; Ctrl+C aborts. Exit code matches the remote process.
|
|
92
92
|
|
|
93
|
+
Arguments pass through transparently — each token is re-quoted before being sent, so spaces and shell metacharacters inside an argument survive intact. Use ordinary single-level quoting, exactly as for a local command. To run a shell snippet (pipes, redirects, `&&`), pass it to a shell explicitly: `cinna exec bash -c '…'`.
|
|
94
|
+
|
|
93
95
|
```bash
|
|
94
96
|
cinna exec python scripts/main.py
|
|
95
97
|
cinna exec pip install pandas
|
|
96
|
-
cinna exec
|
|
98
|
+
cinna exec bash -c 'ls -la'
|
|
99
|
+
cinna exec python -c 'import sys; print(sys.argv)' "a b"
|
|
97
100
|
```
|
|
98
101
|
|
|
99
102
|
### `cinna status`
|
|
@@ -328,6 +328,23 @@ Two surfaces expose conflicts:
|
|
|
328
328
|
|
|
329
329
|
Ctrl+C closes the SSE stream; the platform cleans up the remote process. Interactive stdin (REPLs, debuggers) is out of scope for the current `/exec` endpoint.
|
|
330
330
|
|
|
331
|
+
### Argument quoting
|
|
332
|
+
|
|
333
|
+
There are **two shell passes** between the keyboard and the remote process, and only one round of quoting belongs to each:
|
|
334
|
+
|
|
335
|
+
1. **Local shell** (the caller's terminal / agent Bash tool) splits the command line into argv and strips one layer of quotes. `exec` is declared `nargs=-1`, so Click receives these already-split tokens as a tuple.
|
|
336
|
+
2. **Remote shell** — the platform runs the `command` string through `/bin/sh -c`, re-parsing it a second time.
|
|
337
|
+
|
|
338
|
+
`exec_cmd` (`main.py`) bridges the two with `shlex.join(command)`: it re-quotes each token so the remote `sh -c` reconstructs the *exact* argv the caller typed. This makes `cinna exec` a transparent passthrough — callers write ordinary single-level quoting, exactly as for a local command:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
cinna exec python -c 'import sys; print(sys.argv)' "a b" '[{"x":"y z"}]'
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The historical `" ".join(command)` dropped the word boundaries that the local shell's quoting had implied, so any argument containing a space or a shell metacharacter (`;`, `(`, `{`, …) was re-split or mis-parsed by the remote shell — e.g. `print(sys.argv)` failing with `/bin/sh: Syntax error: word unexpected (expecting ")")`. Regression coverage: `test_exec_command_requotes_args` in `tests/test_main.py`.
|
|
345
|
+
|
|
346
|
+
To run an actual remote shell snippet (pipes, redirects, `&&`), pass it explicitly to a shell: `cinna exec bash -c 'a | b > c'`.
|
|
347
|
+
|
|
331
348
|
---
|
|
332
349
|
|
|
333
350
|
## Bootstrap Flow
|
|
@@ -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,12 @@
|
|
|
1
1
|
"""cinna CLI — local development for Cinna Core agents."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import os
|
|
4
5
|
import platform
|
|
6
|
+
import shlex
|
|
5
7
|
import shutil
|
|
6
8
|
import sys
|
|
9
|
+
import time
|
|
7
10
|
from pathlib import Path
|
|
8
11
|
|
|
9
12
|
import click
|
|
@@ -21,6 +24,8 @@ from cinna.config import (
|
|
|
21
24
|
from cinna.mcp_proxy import run_mcp_proxy
|
|
22
25
|
from cinna.mutagen_runtime import ensure_mutagen_ready
|
|
23
26
|
|
|
27
|
+
logger = logging.getLogger("cinna.exec")
|
|
28
|
+
|
|
24
29
|
|
|
25
30
|
@click.group()
|
|
26
31
|
@click.version_option(version=__version__)
|
|
@@ -106,35 +111,71 @@ def set_token(setup_input: tuple[str, ...], name: str | None):
|
|
|
106
111
|
|
|
107
112
|
|
|
108
113
|
@cli.command(name="exec", context_settings={"ignore_unknown_options": True})
|
|
114
|
+
@click.option(
|
|
115
|
+
"--timeout",
|
|
116
|
+
"-t",
|
|
117
|
+
type=click.IntRange(min=1, max=86400),
|
|
118
|
+
default=1800,
|
|
119
|
+
show_default=True,
|
|
120
|
+
help="Max wall-clock seconds the remote command may run before being killed.",
|
|
121
|
+
)
|
|
109
122
|
@click.argument("command", nargs=-1, required=True)
|
|
110
|
-
def exec_cmd(command: tuple[str, ...]):
|
|
123
|
+
def exec_cmd(timeout: int, command: tuple[str, ...]):
|
|
111
124
|
"""Run a command in the remote agent environment.
|
|
112
125
|
|
|
113
126
|
Output streams back in real time via the platform. Exit code matches the
|
|
114
127
|
remote process's exit code. Ctrl+C aborts the stream.
|
|
115
128
|
|
|
129
|
+
Arguments are passed through transparently: each token you type is
|
|
130
|
+
re-quoted (``shlex.quote``) before being sent, so spaces and shell
|
|
131
|
+
metacharacters inside an argument survive the remote shell intact. Use
|
|
132
|
+
ordinary single-level quoting, exactly as for a local command.
|
|
133
|
+
|
|
116
134
|
Examples:
|
|
117
135
|
cinna exec python scripts/main.py
|
|
118
136
|
cinna exec pip install pandas
|
|
119
137
|
cinna exec bash -c 'ls -la'
|
|
138
|
+
cinna exec python -c 'import sys; print(sys.argv)' "a b"
|
|
139
|
+
cinna exec --timeout 3600 python long_backfill.py
|
|
140
|
+
|
|
141
|
+
If your remote command takes its own ``--timeout`` flag, separate it
|
|
142
|
+
from cinna's option with ``--``:
|
|
143
|
+
|
|
144
|
+
cinna exec --timeout 3600 -- python tool.py --timeout 30
|
|
120
145
|
"""
|
|
121
146
|
root = find_workspace_root()
|
|
122
147
|
config = load_config(root)
|
|
123
148
|
|
|
124
|
-
exit_code = _run_remote_exec(config,
|
|
149
|
+
exit_code = _run_remote_exec(config, shlex.join(command), timeout=timeout)
|
|
125
150
|
sys.exit(exit_code)
|
|
126
151
|
|
|
127
152
|
|
|
128
|
-
def _run_remote_exec(config, command_str: str) -> int:
|
|
153
|
+
def _run_remote_exec(config, command_str: str, timeout: int = 1800) -> int:
|
|
129
154
|
"""Drive the /exec SSE stream and mirror events to the local terminal."""
|
|
130
155
|
exit_code = 0
|
|
156
|
+
exec_id: str | None = None
|
|
157
|
+
started_at = time.monotonic()
|
|
158
|
+
stdout_bytes = 0
|
|
159
|
+
stderr_bytes = 0
|
|
160
|
+
first_delta_at: float | None = None
|
|
161
|
+
terminal_event: str = "no-terminal-event"
|
|
162
|
+
|
|
163
|
+
logger.info(
|
|
164
|
+
"exec start: agent=%s timeout=%ds cmd=%r",
|
|
165
|
+
config.agent_id,
|
|
166
|
+
timeout,
|
|
167
|
+
command_str,
|
|
168
|
+
)
|
|
169
|
+
|
|
131
170
|
with PlatformClient(config) as client:
|
|
132
171
|
try:
|
|
133
|
-
for event in client.stream_exec(
|
|
172
|
+
for event in client.stream_exec(
|
|
173
|
+
config.agent_id, command_str, timeout=timeout
|
|
174
|
+
):
|
|
134
175
|
etype = event.get("type")
|
|
135
176
|
if etype == "exec_id":
|
|
136
|
-
|
|
137
|
-
|
|
177
|
+
exec_id = event.get("exec_id")
|
|
178
|
+
logger.debug("exec_id assigned: %s", exec_id)
|
|
138
179
|
continue
|
|
139
180
|
if etype == "tool_result_delta":
|
|
140
181
|
chunk = event.get("content", "")
|
|
@@ -142,15 +183,45 @@ def _run_remote_exec(config, command_str: str) -> int:
|
|
|
142
183
|
target = sys.stderr if stream == "stderr" else sys.stdout
|
|
143
184
|
target.write(chunk)
|
|
144
185
|
target.flush()
|
|
186
|
+
nbytes = len(chunk.encode("utf-8", errors="replace"))
|
|
187
|
+
if stream == "stderr":
|
|
188
|
+
stderr_bytes += nbytes
|
|
189
|
+
else:
|
|
190
|
+
stdout_bytes += nbytes
|
|
191
|
+
if first_delta_at is None:
|
|
192
|
+
first_delta_at = time.monotonic()
|
|
193
|
+
logger.debug(
|
|
194
|
+
"exec first output (stream=%s, %d bytes) after %.3fs",
|
|
195
|
+
stream, nbytes, first_delta_at - started_at,
|
|
196
|
+
)
|
|
145
197
|
elif etype == "done":
|
|
146
198
|
exit_code = int(event.get("exit_code", 0))
|
|
199
|
+
terminal_event = "done"
|
|
200
|
+
logger.debug("exec done event: exit_code=%s", exit_code)
|
|
147
201
|
elif etype == "interrupted":
|
|
148
202
|
exit_code = int(event.get("exit_code", 130))
|
|
203
|
+
terminal_event = "interrupted"
|
|
204
|
+
logger.info("exec interrupted by remote: exit_code=%s", exit_code)
|
|
149
205
|
elif etype == "error":
|
|
150
|
-
|
|
206
|
+
msg = event.get("content", "unknown error")
|
|
207
|
+
console.error(msg)
|
|
151
208
|
exit_code = 1
|
|
209
|
+
terminal_event = "error"
|
|
210
|
+
logger.error("exec remote error: %s", msg)
|
|
211
|
+
else:
|
|
212
|
+
logger.debug("exec unknown event type=%r: %.200s", etype, event)
|
|
152
213
|
except KeyboardInterrupt:
|
|
153
214
|
exit_code = 130
|
|
215
|
+
terminal_event = "keyboard-interrupt"
|
|
216
|
+
logger.info("exec interrupted locally (Ctrl-C)")
|
|
217
|
+
|
|
218
|
+
duration = time.monotonic() - started_at
|
|
219
|
+
logger.info(
|
|
220
|
+
"exec stop: agent=%s exec_id=%s exit_code=%s duration=%.3fs "
|
|
221
|
+
"stdout=%dB stderr=%dB terminal=%s",
|
|
222
|
+
config.agent_id, exec_id, exit_code, duration,
|
|
223
|
+
stdout_bytes, stderr_bytes, terminal_event,
|
|
224
|
+
)
|
|
154
225
|
return exit_code
|
|
155
226
|
|
|
156
227
|
|
|
@@ -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()
|
|
@@ -215,7 +215,69 @@ def test_exec_command(mock_load, mock_find, mock_exec, runner, workspace_root, s
|
|
|
215
215
|
|
|
216
216
|
result = runner.invoke(cli, ["exec", "python", "scripts/main.py"])
|
|
217
217
|
assert result.exit_code == 0
|
|
218
|
-
mock_exec.assert_called_once_with(
|
|
218
|
+
mock_exec.assert_called_once_with(
|
|
219
|
+
sample_config, "python scripts/main.py", timeout=1800
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@patch("cinna.main._run_remote_exec")
|
|
224
|
+
@patch("cinna.main.find_workspace_root")
|
|
225
|
+
@patch("cinna.main.load_config")
|
|
226
|
+
def test_exec_command_requotes_args(
|
|
227
|
+
mock_load, mock_find, mock_exec, runner, workspace_root, sample_config
|
|
228
|
+
):
|
|
229
|
+
"""Tokens with spaces / shell metacharacters must be re-quoted so the
|
|
230
|
+
remote shell reconstructs the exact argv the caller intended, rather than
|
|
231
|
+
re-splitting them. Regression for `cinna exec` mangling quoted arguments.
|
|
232
|
+
"""
|
|
233
|
+
mock_find.return_value = workspace_root
|
|
234
|
+
mock_load.return_value = sample_config
|
|
235
|
+
mock_exec.return_value = 0
|
|
236
|
+
|
|
237
|
+
result = runner.invoke(
|
|
238
|
+
cli,
|
|
239
|
+
["exec", "python", "-c", "import sys; print(sys.argv)", "a b", '[{"x":"y z"}]'],
|
|
240
|
+
)
|
|
241
|
+
assert result.exit_code == 0
|
|
242
|
+
mock_exec.assert_called_once_with(
|
|
243
|
+
sample_config,
|
|
244
|
+
"""python -c 'import sys; print(sys.argv)' 'a b' '[{"x":"y z"}]'""",
|
|
245
|
+
timeout=1800,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@patch("cinna.main.PlatformClient")
|
|
250
|
+
def test_run_remote_exec_logs_start_and_stop(mock_client_cls, sample_config, caplog):
|
|
251
|
+
"""`cinna exec` must emit start/stop log records — without them this whole
|
|
252
|
+
code path is invisible in cinna.log, which has previously made remote
|
|
253
|
+
failures very hard to debug.
|
|
254
|
+
"""
|
|
255
|
+
import logging
|
|
256
|
+
from cinna.main import _run_remote_exec
|
|
257
|
+
|
|
258
|
+
mock_client = mock_client_cls.return_value.__enter__.return_value
|
|
259
|
+
mock_client.stream_exec.return_value = iter([
|
|
260
|
+
{"type": "exec_id", "exec_id": "ex-1"},
|
|
261
|
+
{"type": "tool_result_delta", "content": "hi\n", "metadata": {"stream": "stdout"}},
|
|
262
|
+
{"type": "tool_result_delta", "content": "warn\n", "metadata": {"stream": "stderr"}},
|
|
263
|
+
{"type": "done", "exit_code": 0},
|
|
264
|
+
])
|
|
265
|
+
|
|
266
|
+
with caplog.at_level(logging.DEBUG, logger="cinna.exec"):
|
|
267
|
+
exit_code = _run_remote_exec(sample_config, "echo hi")
|
|
268
|
+
|
|
269
|
+
assert exit_code == 0
|
|
270
|
+
messages = [r.getMessage() for r in caplog.records if r.name == "cinna.exec"]
|
|
271
|
+
assert any(m.startswith("exec start:") and "echo hi" in m for m in messages)
|
|
272
|
+
assert any("exec_id assigned: ex-1" in m for m in messages)
|
|
273
|
+
assert any(
|
|
274
|
+
m.startswith("exec stop:")
|
|
275
|
+
and "exit_code=0" in m
|
|
276
|
+
and "stdout=3B" in m
|
|
277
|
+
and "stderr=5B" in m
|
|
278
|
+
and "terminal=done" in m
|
|
279
|
+
for m in messages
|
|
280
|
+
)
|
|
219
281
|
|
|
220
282
|
|
|
221
283
|
@patch("cinna.main.sync_session.run_foreground")
|
|
@@ -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
|