vibe-coding-master 0.2.7 → 0.2.8

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.
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env python3
2
+ """Start one VCM long-running validation job with a worker-enforced ceiling.
3
+
4
+ Usage:
5
+ .ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]
6
+
7
+ The command runs in a detached worker process group. The worker itself
8
+ enforces the job ceiling (--timeout, max 60m) and a supervision lease: when
9
+ no foreground watcher renews .ai/vcm/jobs/<job-id>/lease, the worker kills
10
+ the command process group and records the job as orphaned. Watch the job
11
+ with .ai/tools/watch-job in the same turn.
12
+
13
+ Only one validation job may be active at a time.
14
+ """
15
+ import json
16
+ import os
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ import uuid
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ MAX_TIMEOUT_SECONDS = 60 * 60
26
+ DEFAULT_TIMEOUT_SECONDS = MAX_TIMEOUT_SECONDS
27
+ QUEUED_STALE_SECONDS = 60
28
+ ACTIVE_STATUSES = {"queued", "starting", "running"}
29
+ DEFAULT_LEASE_START_GRACE_SECONDS = 300
30
+ DEFAULT_LEASE_RENEW_GRACE_SECONDS = 120
31
+
32
+
33
+ def root_dir() -> Path:
34
+ return Path(__file__).resolve().parents[2]
35
+
36
+
37
+ def jobs_root() -> Path:
38
+ return root_dir() / ".ai/vcm/jobs"
39
+
40
+
41
+ def now_iso() -> str:
42
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
43
+
44
+
45
+ def write_json(path: Path, data: dict) -> None:
46
+ tmp = path.with_suffix(path.suffix + ".tmp")
47
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
48
+ tmp.replace(path)
49
+
50
+
51
+ def read_optional_json(path: Path) -> dict | None:
52
+ try:
53
+ return json.loads(path.read_text())
54
+ except (OSError, ValueError):
55
+ return None
56
+
57
+
58
+ def parse_duration(value: str) -> float:
59
+ value = value.strip().lower()
60
+ if value.endswith("ms"):
61
+ return float(value[:-2]) / 1000
62
+ if value.endswith("s"):
63
+ return float(value[:-1])
64
+ if value.endswith("m"):
65
+ return float(value[:-1]) * 60
66
+ if value.endswith("h"):
67
+ return float(value[:-1]) * 3600
68
+ return float(value)
69
+
70
+
71
+ def env_seconds(name: str, default: float) -> float:
72
+ raw = os.environ.get(name)
73
+ if not raw:
74
+ return default
75
+ try:
76
+ return max(1.0, float(raw))
77
+ except ValueError:
78
+ return default
79
+
80
+
81
+ def job_id() -> str:
82
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
83
+ return f"{timestamp}-{uuid.uuid4().hex[:8]}"
84
+
85
+
86
+ def process_exists(pid: int) -> bool:
87
+ try:
88
+ os.kill(pid, 0)
89
+ return True
90
+ except ProcessLookupError:
91
+ return False
92
+ except PermissionError:
93
+ return True
94
+
95
+
96
+ def stop_command(process: subprocess.Popen) -> str:
97
+ try:
98
+ os.killpg(process.pid, signal.SIGTERM)
99
+ mode = "process-group"
100
+ except ProcessLookupError:
101
+ return "not-running"
102
+ except PermissionError:
103
+ process.terminate()
104
+ mode = "process"
105
+
106
+ try:
107
+ process.wait(timeout=2)
108
+ return f"terminated-{mode}"
109
+ except subprocess.TimeoutExpired:
110
+ pass
111
+
112
+ try:
113
+ os.killpg(process.pid, signal.SIGKILL)
114
+ except (ProcessLookupError, PermissionError):
115
+ process.kill()
116
+ return f"killed-{mode}"
117
+
118
+
119
+ def lease_age_seconds(lease_path: Path) -> float | None:
120
+ try:
121
+ return max(0.0, time.time() - lease_path.stat().st_mtime)
122
+ except OSError:
123
+ return None
124
+
125
+
126
+ def lease_iso(lease_path: Path) -> str | None:
127
+ try:
128
+ mtime = lease_path.stat().st_mtime
129
+ except OSError:
130
+ return None
131
+ return datetime.fromtimestamp(mtime, timezone.utc).isoformat().replace("+00:00", "Z")
132
+
133
+
134
+ def mark_stale(status_path: Path, status: dict, reason: str) -> None:
135
+ stale = dict(status)
136
+ stale.update({"status": "stale", "finishedAt": now_iso(), "staleReason": reason})
137
+ write_json(status_path, stale)
138
+
139
+
140
+ def find_active_job() -> dict | None:
141
+ root = jobs_root()
142
+ if not root.is_dir():
143
+ return None
144
+ for directory in sorted(root.iterdir()):
145
+ if not directory.is_dir():
146
+ continue
147
+ status_path = directory / "status.json"
148
+ status = read_optional_json(status_path)
149
+ if not status or status.get("status") not in ACTIVE_STATUSES:
150
+ continue
151
+ pid = status.get("processId") or status.get("workerPid")
152
+ if isinstance(pid, int):
153
+ if process_exists(pid):
154
+ return status
155
+ mark_stale(status_path, status, "job process not running")
156
+ continue
157
+ try:
158
+ age = time.time() - status_path.stat().st_mtime
159
+ except OSError:
160
+ continue
161
+ if age < QUEUED_STALE_SECONDS:
162
+ return status
163
+ mark_stale(status_path, status, "queued job never started")
164
+ return None
165
+
166
+
167
+ def start_job(command: list[str], timeout_seconds: float) -> int:
168
+ active = find_active_job()
169
+ if active:
170
+ print(
171
+ f"error: validation job {active.get('jobId')} is already {active.get('status')}",
172
+ file=sys.stderr,
173
+ )
174
+ print(f"watch it: .ai/tools/watch-job {active.get('jobId')}", file=sys.stderr)
175
+ print("VCM allows one validation job at a time.", file=sys.stderr)
176
+ return 3
177
+
178
+ job = job_id()
179
+ directory = jobs_root() / job
180
+ directory.mkdir(parents=True, exist_ok=False)
181
+
182
+ (directory / "command.json").write_text(
183
+ json.dumps(
184
+ {
185
+ "command": command,
186
+ "cwd": ".",
187
+ "timeoutSeconds": timeout_seconds,
188
+ "createdAt": now_iso(),
189
+ },
190
+ indent=2,
191
+ sort_keys=True,
192
+ )
193
+ + "\n"
194
+ )
195
+ write_json(
196
+ directory / "status.json",
197
+ {
198
+ "jobId": job,
199
+ "status": "queued",
200
+ "command": command,
201
+ "cwd": ".",
202
+ "timeoutSeconds": timeout_seconds,
203
+ "startedAt": None,
204
+ "finishedAt": None,
205
+ "exitCode": None,
206
+ "durationSeconds": None,
207
+ "workerPid": None,
208
+ "processId": None,
209
+ },
210
+ )
211
+
212
+ subprocess.Popen(
213
+ [sys.executable, str(Path(__file__).resolve()), "--worker", job],
214
+ cwd=root_dir(),
215
+ stdin=subprocess.DEVNULL,
216
+ stdout=subprocess.DEVNULL,
217
+ stderr=subprocess.DEVNULL,
218
+ start_new_session=True,
219
+ )
220
+
221
+ print(f"job: {job}")
222
+ print(f"ceiling: {int(timeout_seconds)}s (worker-enforced)")
223
+ print(f"watch: .ai/tools/watch-job {job}")
224
+ return 0
225
+
226
+
227
+ def run_worker(job: str) -> int:
228
+ directory = jobs_root() / job
229
+ command_path = directory / "command.json"
230
+ status_path = directory / "status.json"
231
+ lease_path = directory / "lease"
232
+
233
+ payload = json.loads(command_path.read_text())
234
+ command = payload["command"]
235
+ timeout_seconds = min(
236
+ float(payload.get("timeoutSeconds") or DEFAULT_TIMEOUT_SECONDS),
237
+ MAX_TIMEOUT_SECONDS,
238
+ )
239
+ start_grace = env_seconds("VCM_JOB_LEASE_START_GRACE_SECONDS", DEFAULT_LEASE_START_GRACE_SECONDS)
240
+ renew_grace = env_seconds("VCM_JOB_LEASE_RENEW_GRACE_SECONDS", DEFAULT_LEASE_RENEW_GRACE_SECONDS)
241
+
242
+ worker_started = time.time()
243
+ base = {
244
+ "jobId": job,
245
+ "command": command,
246
+ "cwd": ".",
247
+ "timeoutSeconds": timeout_seconds,
248
+ "workerPid": os.getpid(),
249
+ }
250
+ write_json(
251
+ status_path,
252
+ {
253
+ **base,
254
+ "status": "starting",
255
+ "startedAt": None,
256
+ "finishedAt": None,
257
+ "exitCode": None,
258
+ "durationSeconds": None,
259
+ "processId": None,
260
+ },
261
+ )
262
+
263
+ started = time.time()
264
+ started_at = now_iso()
265
+ verdict = None
266
+ with (directory / "stdout.log").open("wb") as stdout, (directory / "stderr.log").open("wb") as stderr:
267
+ process = subprocess.Popen(
268
+ command,
269
+ cwd=root_dir(),
270
+ stdout=stdout,
271
+ stderr=stderr,
272
+ start_new_session=True,
273
+ )
274
+ running = {
275
+ **base,
276
+ "status": "running",
277
+ "startedAt": started_at,
278
+ "finishedAt": None,
279
+ "exitCode": None,
280
+ "durationSeconds": None,
281
+ "processId": process.pid,
282
+ }
283
+ write_json(status_path, running)
284
+
285
+ while True:
286
+ exit_code = process.poll()
287
+ if exit_code is not None:
288
+ break
289
+
290
+ if time.time() - started >= timeout_seconds:
291
+ stop_result = stop_command(process)
292
+ verdict = (
293
+ "timeout",
294
+ {"processStopResult": stop_result},
295
+ )
296
+ exit_code = process.wait()
297
+ break
298
+
299
+ lease_age = lease_age_seconds(lease_path)
300
+ if lease_age is None:
301
+ if time.time() - worker_started >= start_grace:
302
+ stop_result = stop_command(process)
303
+ verdict = (
304
+ "orphaned",
305
+ {
306
+ "orphanReason": f"no watcher within {int(start_grace)}s",
307
+ "lastWatchedAt": None,
308
+ "processStopResult": stop_result,
309
+ },
310
+ )
311
+ exit_code = process.wait()
312
+ break
313
+ elif lease_age >= renew_grace:
314
+ stop_result = stop_command(process)
315
+ verdict = (
316
+ "orphaned",
317
+ {
318
+ "orphanReason": f"lease not renewed for {int(lease_age)}s",
319
+ "lastWatchedAt": lease_iso(lease_path),
320
+ "processStopResult": stop_result,
321
+ },
322
+ )
323
+ exit_code = process.wait()
324
+ break
325
+
326
+ time.sleep(1)
327
+
328
+ duration = round(time.time() - started, 3)
329
+ current = read_optional_json(status_path) or {}
330
+ if current.get("status") in {"timeout", "orphaned", "stale"}:
331
+ current["processExitCode"] = exit_code
332
+ current["processFinishedAt"] = now_iso()
333
+ current["processDurationSeconds"] = duration
334
+ write_json(status_path, current)
335
+ return 0
336
+
337
+ final = {
338
+ **base,
339
+ "startedAt": started_at,
340
+ "finishedAt": now_iso(),
341
+ "durationSeconds": duration,
342
+ "processId": process.pid,
343
+ }
344
+ if verdict is None:
345
+ final.update({"status": "success" if exit_code == 0 else "failed", "exitCode": exit_code})
346
+ else:
347
+ kind, extra = verdict
348
+ final.update({"status": kind, "exitCode": None, "processExitCode": exit_code, **extra})
349
+ write_json(status_path, final)
350
+ return 0
351
+
352
+
353
+ def main() -> int:
354
+ argv = sys.argv[1:]
355
+ if len(argv) >= 2 and argv[0] == "--worker":
356
+ return run_worker(argv[1])
357
+
358
+ timeout_seconds = DEFAULT_TIMEOUT_SECONDS
359
+ index = 0
360
+ while index < len(argv) and argv[index] != "--":
361
+ arg = argv[index]
362
+ raw = None
363
+ if arg == "--timeout":
364
+ if index + 1 >= len(argv):
365
+ print("error: --timeout needs a duration", file=sys.stderr)
366
+ return 2
367
+ raw = argv[index + 1]
368
+ index += 2
369
+ elif arg.startswith("--timeout="):
370
+ raw = arg[len("--timeout="):]
371
+ index += 1
372
+ else:
373
+ print(f"error: unknown option: {arg}", file=sys.stderr)
374
+ return 2
375
+ try:
376
+ timeout_seconds = parse_duration(raw)
377
+ except ValueError:
378
+ print(f"error: invalid duration: {raw}", file=sys.stderr)
379
+ return 2
380
+
381
+ if index >= len(argv) or argv[index] != "--" or index + 1 >= len(argv):
382
+ print(
383
+ "Usage: .ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]",
384
+ file=sys.stderr,
385
+ )
386
+ return 2
387
+ if timeout_seconds <= 0:
388
+ print("error: timeout must be positive", file=sys.stderr)
389
+ return 2
390
+ if timeout_seconds > MAX_TIMEOUT_SECONDS:
391
+ print(
392
+ "error: timeout exceeds maximum 60m; split the work or get user approval",
393
+ file=sys.stderr,
394
+ )
395
+ return 2
396
+
397
+ return start_job(argv[index + 1:], timeout_seconds)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ raise SystemExit(main())
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """VCM PreToolUse guard: deny background Bash inside VCM role sessions.
3
+
4
+ Reads the Claude Code PreToolUse hook payload on stdin. When the Bash tool
5
+ call would start background work (run_in_background, nohup, setsid, disown,
6
+ or a lone '&'), it prints a deny decision that redirects the role to the
7
+ vcm-long-running-validation skill. Anything else is allowed by staying
8
+ silent.
9
+
10
+ Quoted payloads of `sh -c` / `bash -lc` style invocations are executable
11
+ shell code, so they are scanned recursively. `.ai/tools/run-long-check` is the
12
+ only sanctioned detached worker; the command it runs must still stay in the
13
+ supervised foreground process group.
14
+ """
15
+ import json
16
+ import re
17
+ import sys
18
+
19
+ SKILL_HINT = (
20
+ "Use the vcm-long-running-validation skill instead: "
21
+ "`.ai/tools/run-long-check --timeout <duration> -- <command>` then "
22
+ "`.ai/tools/watch-job <job-id>` in the same turn, repeating watch-job "
23
+ "until it reports a terminal result."
24
+ )
25
+
26
+ MAX_NESTED_SHELL_DEPTH = 3
27
+ QUOTED = re.compile(r"'([^']*)'|\"([^\"]*)\"")
28
+ SHELL_DASH_C = re.compile(r"(?:^|[\s;&|(])(?:sh|bash|zsh|dash|ksh)\s+(?:-\w+\s+)*-\w*c\w*(?:\s|$)")
29
+
30
+
31
+ def strip_quoted(command: str) -> str:
32
+ return QUOTED.sub(" ", command)
33
+
34
+
35
+ def quoted_segments(command: str) -> list[str]:
36
+ return [single or double for single, double in QUOTED.findall(command)]
37
+
38
+
39
+ def unquoted_ampersand(command_without_quotes: str) -> bool:
40
+ cleaned = re.sub(r"\d*>&\d*", " ", command_without_quotes)
41
+ cleaned = re.sub(r"<&\d*", " ", cleaned)
42
+ cleaned = cleaned.replace("&&", " ")
43
+ return bool(re.search(r"&\s*(?:$|[;\n)])", cleaned) or re.search(r"\s&\s", cleaned))
44
+
45
+
46
+ def scan_shell_command(command: str, depth: int = 0) -> list[str]:
47
+ reasons = []
48
+ stripped = strip_quoted(command)
49
+ if re.search(r"(?:^|[\s;&|(])(?:nohup|setsid)(?:\s|$)", stripped):
50
+ reasons.append("nohup/setsid detach is forbidden")
51
+ if re.search(r"(?:^|[\s;&|(])disown(?:\s|$)", stripped):
52
+ reasons.append("disown is forbidden")
53
+ if unquoted_ampersand(stripped):
54
+ reasons.append("'&' background execution is forbidden")
55
+
56
+ # `sh -c '...'` quoted payloads are shell code, not plain strings.
57
+ if depth < MAX_NESTED_SHELL_DEPTH and SHELL_DASH_C.search(stripped):
58
+ for segment in quoted_segments(command):
59
+ reasons.extend(scan_shell_command(segment, depth + 1))
60
+ return reasons
61
+
62
+
63
+ def background_reasons(tool_input: dict) -> list[str]:
64
+ reasons = []
65
+ if tool_input.get("run_in_background"):
66
+ reasons.append("Bash run_in_background is forbidden")
67
+
68
+ command = tool_input.get("command")
69
+ command = command if isinstance(command, str) else ""
70
+
71
+ reasons.extend(scan_shell_command(command))
72
+ return list(dict.fromkeys(reasons))
73
+
74
+
75
+ def main() -> int:
76
+ raw = sys.stdin.read()
77
+ try:
78
+ payload = json.loads(raw) if raw.strip() else {}
79
+ except ValueError:
80
+ return 0
81
+ if payload.get("tool_name") != "Bash":
82
+ return 0
83
+
84
+ tool_input = payload.get("tool_input")
85
+ tool_input = tool_input if isinstance(tool_input, dict) else {}
86
+ reasons = background_reasons(tool_input)
87
+ if not reasons:
88
+ return 0
89
+
90
+ print(
91
+ json.dumps(
92
+ {
93
+ "hookSpecificOutput": {
94
+ "hookEventName": "PreToolUse",
95
+ "permissionDecision": "deny",
96
+ "permissionDecisionReason": (
97
+ "VCM forbids background Bash (" + "; ".join(reasons) + "). " + SKILL_HINT
98
+ ),
99
+ }
100
+ }
101
+ )
102
+ )
103
+ return 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """Watch one VCM long-running validation job in the foreground.
3
+
4
+ Usage:
5
+ .ai/tools/watch-job <job-id> [--window <duration>] [--interval <duration>]
6
+
7
+ watch-job renews the job supervision lease while it runs. When the watch
8
+ window elapses and the job is still running, it exits 125 WITHOUT stopping
9
+ the job; call watch-job again immediately in the same turn. The job ceiling
10
+ itself is enforced by the run-long-check worker, not by watch-job.
11
+
12
+ Exit codes:
13
+ 0 success
14
+ 1 failed
15
+ 124 timeout (job hit its ceiling and was killed by the worker)
16
+ 125 still running; call watch-job again now
17
+ 4 orphaned or stale (job lost supervision and was killed, or its worker died)
18
+ 2 usage error or unknown job id
19
+ """
20
+ import argparse
21
+ import json
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ MAX_WINDOW_SECONDS = 8 * 60
27
+ DEFAULT_WINDOW = "8m"
28
+ STATUS_WAIT_SECONDS = 10
29
+ TERMINAL_EXIT_CODES = {
30
+ "success": 0,
31
+ "failed": 1,
32
+ "timeout": 124,
33
+ "orphaned": 4,
34
+ "stale": 4,
35
+ }
36
+
37
+
38
+ def root_dir() -> Path:
39
+ return Path(__file__).resolve().parents[2]
40
+
41
+
42
+ def parse_duration(value: str) -> float:
43
+ value = value.strip().lower()
44
+ if value.endswith("ms"):
45
+ return float(value[:-2]) / 1000
46
+ if value.endswith("s"):
47
+ return float(value[:-1])
48
+ if value.endswith("m"):
49
+ return float(value[:-1]) * 60
50
+ if value.endswith("h"):
51
+ return float(value[:-1]) * 3600
52
+ return float(value)
53
+
54
+
55
+ def read_optional_json(path: Path) -> dict | None:
56
+ try:
57
+ return json.loads(path.read_text())
58
+ except (OSError, ValueError):
59
+ return None
60
+
61
+
62
+ def renew_lease(lease_path: Path) -> None:
63
+ try:
64
+ lease_path.touch()
65
+ except OSError:
66
+ pass
67
+
68
+
69
+ def tail(path: Path, lines: int = 40, max_bytes: int = 65536) -> str:
70
+ try:
71
+ size = path.stat().st_size
72
+ except OSError:
73
+ return ""
74
+ try:
75
+ with path.open("rb") as handle:
76
+ handle.seek(max(0, size - max_bytes))
77
+ data = handle.read(max_bytes)
78
+ except OSError:
79
+ return ""
80
+ text = data.decode("utf-8", errors="replace")
81
+ return "\n".join(text.splitlines()[-lines:])
82
+
83
+
84
+ def elapsed_seconds(status: dict) -> float | None:
85
+ started_at = status.get("startedAt")
86
+ if not started_at:
87
+ return None
88
+ from datetime import datetime, timezone
89
+
90
+ try:
91
+ started = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
92
+ except ValueError:
93
+ return None
94
+ return round((datetime.now(timezone.utc) - started).total_seconds(), 3)
95
+
96
+
97
+ def print_summary(job_id: str, status: dict, directory: Path) -> None:
98
+ state = status.get("status")
99
+ print(f"job: {job_id}")
100
+ print(f"status: {state}")
101
+ print(f"exitCode: {status.get('exitCode')}")
102
+ print(f"durationSeconds: {status.get('durationSeconds')}")
103
+ if state == "timeout":
104
+ print(f"timeoutSeconds: {status.get('timeoutSeconds')}")
105
+ print(f"processStopResult: {status.get('processStopResult')}")
106
+ if state == "orphaned":
107
+ print(f"orphanReason: {status.get('orphanReason')}")
108
+ print(f"processStopResult: {status.get('processStopResult')}")
109
+ if state == "stale":
110
+ print(f"staleReason: {status.get('staleReason')}")
111
+
112
+ if state in {"failed", "timeout", "orphaned", "stale"}:
113
+ stdout_tail = tail(directory / "stdout.log")
114
+ stderr_tail = tail(directory / "stderr.log")
115
+ if stdout_tail:
116
+ print("\nstdout tail:")
117
+ print(stdout_tail)
118
+ if stderr_tail:
119
+ print("\nstderr tail:")
120
+ print(stderr_tail)
121
+
122
+
123
+ def print_progress(job_id: str, status: dict, directory: Path, window: float) -> None:
124
+ print(f"job: {job_id}")
125
+ print("status: still-running")
126
+ print(f"jobStatus: {status.get('status')}")
127
+ elapsed = elapsed_seconds(status)
128
+ if elapsed is not None:
129
+ print(f"elapsedSeconds: {elapsed}")
130
+ ceiling = status.get("timeoutSeconds")
131
+ if isinstance(ceiling, (int, float)):
132
+ print(f"ceilingRemainingSeconds: {max(0, round(ceiling - elapsed))}")
133
+ print(f"watchWindowSeconds: {int(window)}")
134
+ stdout_tail = tail(directory / "stdout.log", lines=5)
135
+ if stdout_tail:
136
+ print("\nstdout tail:")
137
+ print(stdout_tail)
138
+ print("\njob still running - call .ai/tools/watch-job again now; do not end the turn.")
139
+
140
+
141
+ def main() -> int:
142
+ parser = argparse.ArgumentParser(
143
+ description="Watch a file-backed long-running validation job."
144
+ )
145
+ parser.add_argument("job_id")
146
+ parser.add_argument("--window", default=DEFAULT_WINDOW)
147
+ parser.add_argument("--interval", default="1s")
148
+ parser.add_argument("--timeout", dest="legacy_timeout", default=None, help=argparse.SUPPRESS)
149
+ args = parser.parse_args()
150
+
151
+ if args.legacy_timeout is not None:
152
+ print(
153
+ "error: watch-job no longer takes --timeout; the job ceiling is set by"
154
+ " run-long-check --timeout. Use --window (max 8m) and call watch-job"
155
+ " repeatedly until it reports a terminal result.",
156
+ file=sys.stderr,
157
+ )
158
+ return 2
159
+
160
+ try:
161
+ window = parse_duration(args.window)
162
+ interval = parse_duration(args.interval)
163
+ except ValueError as exc:
164
+ print(f"error: invalid duration: {exc}", file=sys.stderr)
165
+ return 2
166
+ if window <= 0 or interval <= 0:
167
+ print("error: window and interval must be positive", file=sys.stderr)
168
+ return 2
169
+ if window > MAX_WINDOW_SECONDS:
170
+ print("error: window exceeds maximum 8m; call watch-job repeatedly instead", file=sys.stderr)
171
+ return 2
172
+
173
+ directory = root_dir() / ".ai/vcm/jobs" / args.job_id
174
+ status_path = directory / "status.json"
175
+ lease_path = directory / "lease"
176
+
177
+ found_deadline = time.time() + STATUS_WAIT_SECONDS
178
+ while not status_path.is_file():
179
+ if time.time() >= found_deadline:
180
+ print(f"error: unknown job id: {args.job_id} (no {status_path})", file=sys.stderr)
181
+ return 2
182
+ time.sleep(0.2)
183
+
184
+ deadline = time.time() + window
185
+ last_status: dict = {}
186
+ while True:
187
+ renew_lease(lease_path)
188
+ status = read_optional_json(status_path)
189
+ if status:
190
+ last_status = status
191
+ state = status.get("status")
192
+ if state in TERMINAL_EXIT_CODES:
193
+ print_summary(args.job_id, status, directory)
194
+ return TERMINAL_EXIT_CODES[state]
195
+
196
+ if time.time() >= deadline:
197
+ print_progress(args.job_id, last_status, directory, window)
198
+ return 125
199
+
200
+ time.sleep(interval)
201
+
202
+
203
+ if __name__ == "__main__":
204
+ raise SystemExit(main())