|
6 | 6 | import sys |
7 | 7 | from typing import List |
8 | 8 |
|
9 | | -from lab import Job |
| 9 | +from lab import Job, storage |
10 | 10 |
|
11 | 11 |
|
12 | 12 | async def _set_live_status_async(job_id: str, status: str) -> None: |
@@ -43,6 +43,54 @@ def _set_live_status(status: str) -> None: |
43 | 43 | return |
44 | 44 |
|
45 | 45 |
|
| 46 | +async def _write_provider_logs_async(job_id: str, logs_text: str) -> None: |
| 47 | + """ |
| 48 | + Best-effort helper to write combined stdout/stderr logs to the job directory. |
| 49 | +
|
| 50 | + Uses _TFL_JOB_ID to resolve the job directory via lab.dirs.get_job_dir, then |
| 51 | + writes provider_logs.txt using the storage abstraction. |
| 52 | + """ |
| 53 | + try: |
| 54 | + # Import inside helper to avoid circular imports at module load time. |
| 55 | + from lab.dirs import get_job_dir |
| 56 | + |
| 57 | + job_dir = await get_job_dir(job_id) |
| 58 | + log_path = storage.join(job_dir, "provider_logs.txt") |
| 59 | + |
| 60 | + # Ensure the directory exists (no-op for remote storage that doesn't require mkdirs). |
| 61 | + try: |
| 62 | + await storage.makedirs(job_dir, exist_ok=True) |
| 63 | + except Exception: |
| 64 | + # Some storage backends may not support makedirs for virtual paths; ignore. |
| 65 | + pass |
| 66 | + |
| 67 | + async with await storage.open(log_path, "w", encoding="utf-8") as f: |
| 68 | + await f.write(logs_text or "") |
| 69 | + except Exception: |
| 70 | + # Never let logging failures break the wrapped command. |
| 71 | + return |
| 72 | + |
| 73 | + |
| 74 | +def _write_provider_logs(logs_text: str) -> None: |
| 75 | + """Entry-point wrapper for writing provider logs for the current job.""" |
| 76 | + job_id = os.environ.get("_TFL_JOB_ID") |
| 77 | + if not job_id or logs_text is None: |
| 78 | + return |
| 79 | + |
| 80 | + try: |
| 81 | + asyncio.run(_write_provider_logs_async(job_id, logs_text)) |
| 82 | + except RuntimeError: |
| 83 | + # Fallback in case an event loop already exists. |
| 84 | + try: |
| 85 | + loop = asyncio.get_event_loop() |
| 86 | + if loop.is_running(): |
| 87 | + loop.create_task(_write_provider_logs_async(job_id, logs_text)) |
| 88 | + else: |
| 89 | + loop.run_until_complete(_write_provider_logs_async(job_id, logs_text)) |
| 90 | + except Exception: |
| 91 | + return |
| 92 | + |
| 93 | + |
46 | 94 | def main(argv: List[str] | None = None) -> int: |
47 | 95 | """ |
48 | 96 | Wrapper entrypoint for remote jobs. |
@@ -74,7 +122,40 @@ def main(argv: List[str] | None = None) -> int: |
74 | 122 | _set_live_status("started") |
75 | 123 |
|
76 | 124 | # Run the original command in the shell so it behaves exactly as submitted. |
77 | | - completed = subprocess.run(command_str, shell=True) |
| 125 | + # Capture stdout/stderr so we can save a copy to provider_logs.txt while still |
| 126 | + # echoing output to the current process streams. |
| 127 | + completed = subprocess.run( |
| 128 | + command_str, |
| 129 | + shell=True, |
| 130 | + capture_output=True, |
| 131 | + text=True, |
| 132 | + ) |
| 133 | + |
| 134 | + # Echo captured output back to the current stdout/stderr so provider-native logs |
| 135 | + # (e.g., SkyPilot, SLURM, RunPod) still see the same content. |
| 136 | + if completed.stdout: |
| 137 | + try: |
| 138 | + sys.stdout.write(completed.stdout) |
| 139 | + sys.stdout.flush() |
| 140 | + except Exception: |
| 141 | + pass |
| 142 | + if completed.stderr: |
| 143 | + try: |
| 144 | + sys.stderr.write(completed.stderr) |
| 145 | + sys.stderr.flush() |
| 146 | + except Exception: |
| 147 | + pass |
| 148 | + |
| 149 | + # Combine stdout + stderr into a single text blob and store it alongside the job. |
| 150 | + combined_logs_parts: List[str] = [] |
| 151 | + if completed.stdout: |
| 152 | + combined_logs_parts.append(completed.stdout) |
| 153 | + if completed.stderr: |
| 154 | + combined_logs_parts.append(completed.stderr) |
| 155 | + combined_logs = "\n".join(part.rstrip("\n") for part in combined_logs_parts) |
| 156 | + |
| 157 | + _write_provider_logs(combined_logs) |
| 158 | + |
78 | 159 | exit_code = completed.returncode |
79 | 160 |
|
80 | 161 | # Update live_status based on outcome (best-effort). |
|
0 commit comments