Taimwe commited on
Commit
ee4da68
·
verified ·
1 Parent(s): 51a4d63

Handoff: fixed supervisor + full findings/state so work can resume

Browse files
Files changed (1) hide show
  1. pipeline.py +214 -0
pipeline.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SecureCoder v2 pipeline supervisor (stdlib only).
2
+ #
3
+ # Waits for the v2 training job to leave the active set, then chains the merge
4
+ # job and the GGUF quantise job so the pipeline finishes unattended.
5
+ #
6
+ # HF_TOKEN - fine-grained token with job.write + repo.write
7
+ # TRAIN_JOB - id of the training job to wait on (required unless SKIP_MERGE=1)
8
+ # QUANT - GGUF quantisation to produce (default Q4_K_M)
9
+ #
10
+ # Changes vs the original supervisor:
11
+ # * BUG FIX: the SKIP_MERGE=1 path no longer dies with UnboundLocalError when
12
+ # logging the final summary. Every step's outcome is seeded in RESULT up
13
+ # front, so the summary can never reference an unbound name.
14
+ # * TRAIN_JOB is validated before polling instead of hitting a malformed URL.
15
+ # * api()/repo_ok() retry transient network errors, 5xx and 429 with backoff.
16
+ # * stage_of() treats a not-yet-visible job as NOT_FOUND instead of raising,
17
+ # which matters right after launch().
18
+ # * Failures are reported as clean log lines + exit codes, not tracebacks.
19
+
20
+ import json
21
+ import os
22
+ import time
23
+ import urllib.error
24
+ import urllib.request
25
+
26
+ HUB = "https://huggingface.co"
27
+ NS = "Taimwe"
28
+ TOKEN = os.environ.get("HF_TOKEN", "")
29
+ MERGE_REPO = os.environ.get("MERGE_REPO", "Taimwe/securecoder-30b-pro-v2-merged")
30
+ TRAIN_JOB = os.environ.get("TRAIN_JOB", "")
31
+ ADAPTER = os.environ.get("ADAPTER_REPO", "Taimwe/securecoder-30b-pro-v2")
32
+ MERGED = MERGE_REPO
33
+ GGUF = os.environ.get("GGUF_REPO", "Taimwe/securecoder-30b-pro-v2-GGUF")
34
+ IMAGE = "ghcr.io/astral-sh/uv:python3.12-bookworm"
35
+ ACTIVE = {"CREATED", "SCHEDULING", "RUNNING", "QUEUED", "PENDING"}
36
+ OK = {"COMPLETED", "SUCCEEDED"}
37
+ SKIP_MERGE = os.environ.get("SKIP_MERGE", "0") == "1"
38
+ QUANT = os.environ.get("QUANT", "Q4_K_M")
39
+ RETRY_ATTEMPTS = int(os.environ.get("RETRY_ATTEMPTS", "4"))
40
+
41
+ # Seeded before any work starts - this is what makes the summary crash-proof.
42
+ RESULT = {"train": "NOT_RUN", "merge": "NOT_RUN", "gguf": "NOT_RUN",
43
+ "adapter": ADAPTER, "merged": MERGED, "gguf_repo": GGUF, "quant": QUANT}
44
+
45
+
46
+ def log(msg):
47
+ print("[supervisor %s] %s" % (time.strftime("%H:%M:%S"), msg), flush=True)
48
+
49
+
50
+ def _backoff(attempt):
51
+ return min(60, 5 * 2 ** (attempt - 1))
52
+
53
+
54
+ def api(path, payload=None):
55
+ """Call the HF API, retrying transient failures with exponential backoff."""
56
+ data = json.dumps(payload).encode() if payload is not None else None
57
+ last = "unknown"
58
+ for attempt in range(1, RETRY_ATTEMPTS + 1):
59
+ headers = {"Authorization": "Bearer " + TOKEN}
60
+ if data is not None:
61
+ headers["Content-Type"] = "application/json"
62
+ req = urllib.request.Request(
63
+ HUB + path, data=data, headers=headers,
64
+ method="POST" if data is not None else "GET")
65
+ try:
66
+ with urllib.request.urlopen(req, timeout=120) as resp:
67
+ return json.loads(resp.read().decode() or "null")
68
+ except urllib.error.HTTPError as exc:
69
+ body = exc.read().decode("utf-8", "replace")[:300]
70
+ last = "HTTP %s %s %s" % (exc.code, exc.reason, body)
71
+ if exc.code < 500 and exc.code != 429:
72
+ raise RuntimeError("%s -> %s" % (path, last))
73
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
74
+ last = "%s: %s" % (type(exc).__name__, exc)
75
+ if attempt < RETRY_ATTEMPTS:
76
+ wait = _backoff(attempt)
77
+ log("retry %d/%d in %ds (%s)" % (attempt, RETRY_ATTEMPTS, wait, last))
78
+ time.sleep(wait)
79
+ raise RuntimeError("%s failed after %d attempts -> %s" % (path, RETRY_ATTEMPTS, last))
80
+ def stage_of(job_id):
81
+ """Current stage of a job. A job that is not visible yet is NOT_FOUND."""
82
+ try:
83
+ return api("/api/jobs/%s/%s" % (NS, job_id))["status"]["stage"]
84
+ except RuntimeError as exc:
85
+ if "HTTP 404" in str(exc):
86
+ return "NOT_FOUND"
87
+ raise
88
+
89
+
90
+ def repo_ok(repo):
91
+ """True if the model repo exists. 404 -> False; transient errors -> retried."""
92
+ for attempt in range(1, RETRY_ATTEMPTS + 1):
93
+ try:
94
+ req = urllib.request.Request(
95
+ HUB + "/api/models/" + repo,
96
+ headers={"Authorization": "Bearer " + TOKEN})
97
+ with urllib.request.urlopen(req, timeout=60) as resp:
98
+ return resp.status == 200
99
+ except urllib.error.HTTPError as exc:
100
+ if exc.code == 404:
101
+ return False
102
+ if (exc.code >= 500 or exc.code == 429) and attempt < RETRY_ATTEMPTS:
103
+ time.sleep(_backoff(attempt))
104
+ continue
105
+ raise RuntimeError("repo_ok(%s) -> HTTP %s" % (repo, exc.code))
106
+ except (urllib.error.URLError, TimeoutError, OSError):
107
+ if attempt == RETRY_ATTEMPTS:
108
+ raise
109
+ time.sleep(_backoff(attempt))
110
+ return False
111
+
112
+
113
+ def wait_for(job_id, label, budget_s):
114
+ """Poll until the job leaves the active set, logging only on change."""
115
+ deadline = time.time() + budget_s
116
+ seen = None
117
+ while True:
118
+ stage = stage_of(job_id)
119
+ if stage != seen:
120
+ log("%s %s stage=%s" % (label, job_id, stage))
121
+ seen = stage
122
+ if stage not in ACTIVE:
123
+ return stage
124
+ if time.time() > deadline:
125
+ log("%s %s exceeded its %ds budget" % (label, job_id, budget_s))
126
+ return "TIMEOUT"
127
+ time.sleep(60)
128
+
129
+
130
+ def launch(command, flavor, timeout_s, name):
131
+ # The secret must carry the real token value: an empty string is injected
132
+ # literally and every push/upload then fails with 401.
133
+ job = api("/api/jobs/" + NS, {
134
+ "flavor": flavor,
135
+ "dockerImage": IMAGE,
136
+ "command": command,
137
+ "arguments": [],
138
+ "environment": {},
139
+ "secrets": {"HF_TOKEN": TOKEN},
140
+ "timeoutSeconds": timeout_s,
141
+ "labels": {"name": name},
142
+ })
143
+ log("launched %s %s (%s)" % (name, job["id"], flavor))
144
+ return job["id"]
145
+ def main():
146
+ if not TOKEN.strip():
147
+ log("HF_TOKEN is empty - set it to a token with job.write + repo.write")
148
+ return 2
149
+
150
+ if SKIP_MERGE:
151
+ log("merge already done upstream; going straight to GGUF")
152
+ if not repo_ok(MERGED):
153
+ log("merged repo %s missing; stopping" % MERGED)
154
+ RESULT["merge"] = "MISSING_REPO"
155
+ log("SUMMARY " + json.dumps(RESULT))
156
+ return 1
157
+ log("merged repo %s is live" % MERGED)
158
+ RESULT["merge"] = "SKIPPED"
159
+ else:
160
+ if not TRAIN_JOB.strip():
161
+ log("TRAIN_JOB is required unless SKIP_MERGE=1")
162
+ return 2
163
+ log("watching training job %s" % TRAIN_JOB)
164
+ final = wait_for(TRAIN_JOB, "train", 6 * 3600)
165
+ RESULT["train"] = final
166
+ log("training finished stage=%s" % final)
167
+ if final not in OK:
168
+ log("training did not complete cleanly; not chaining")
169
+ log("SUMMARY " + json.dumps(RESULT))
170
+ return 1
171
+ if not repo_ok(ADAPTER):
172
+ log("adapter %s missing after training; stopping" % ADAPTER)
173
+ log("SUMMARY " + json.dumps(RESULT))
174
+ return 1
175
+ log("adapter %s is live" % ADAPTER)
176
+
177
+ merge_job = launch(
178
+ ["uv", "run", "--no-project",
179
+ HUB + "/" + NS + "/securecoder-scripts/resolve/main/merge_securecoder.py",
180
+ "--adapter", ADAPTER, "--output-repo", MERGED],
181
+ "a100-large", 3 * 3600, "securecoder-v2-merge")
182
+ merge_final = wait_for(merge_job, "merge", 4 * 3600)
183
+ RESULT["merge"] = merge_final
184
+ log("merge finished stage=%s" % merge_final)
185
+ if merge_final not in OK or not repo_ok(MERGED):
186
+ log("merge failed or repo missing; skipping GGUF")
187
+ log("SUMMARY " + json.dumps(RESULT))
188
+ return 1
189
+ log("merged repo %s is live" % MERGED)
190
+
191
+ gguf_job = launch(
192
+ ["uv", "run", "--no-project",
193
+ HUB + "/" + NS + "/securecoder-scripts/resolve/main/quantise_securecoder.py",
194
+ "--merged-repo", MERGED, "--gguf-repo", GGUF, "--quant", QUANT],
195
+ "cpu-performance", 6 * 3600, "securecoder-v2-gguf")
196
+ gguf_final = wait_for(gguf_job, "gguf", 7 * 3600)
197
+ RESULT["gguf"] = gguf_final
198
+
199
+ if repo_ok(GGUF):
200
+ log("PIPELINE COMPLETE gguf=%s merged=%s quant=%s" % (GGUF, MERGED, QUANT))
201
+ else:
202
+ log("PIPELINE ENDED but %s not found" % GGUF)
203
+
204
+ log("SUMMARY " + json.dumps(RESULT))
205
+ return 0
206
+
207
+
208
+ if __name__ == "__main__":
209
+ try:
210
+ raise SystemExit(main())
211
+ except RuntimeError as exc:
212
+ log("FATAL %s" % exc)
213
+ log("SUMMARY " + json.dumps(RESULT))
214
+ raise SystemExit(1)