solvewright 0.1.0__py3-none-any.whl

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.
Files changed (31) hide show
  1. solvewright/__init__.py +3 -0
  2. solvewright/__main__.py +3 -0
  3. solvewright/agents.py +450 -0
  4. solvewright/cli.py +263 -0
  5. solvewright/connection.py +137 -0
  6. solvewright/interviewer/cases/portals/brief.txt +13 -0
  7. solvewright/interviewer/cases/portals/interviewer.txt +49 -0
  8. solvewright/interviewer/cases/portals/reported-round.txt +34 -0
  9. solvewright/interviewer/data/portal-samples/sample-01.json +967 -0
  10. solvewright/interviewer/data/portal-samples/sample-02.json +967 -0
  11. solvewright/interviewer/data/portal-samples/sample-03.json +967 -0
  12. solvewright/interviewer/data/portal-samples/sample-04.json +967 -0
  13. solvewright/interviewer/data/portal-samples/sample-05.json +967 -0
  14. solvewright/interviewer/data/portal-samples/sample-06.json +967 -0
  15. solvewright/interviewer/data/portal-samples/sample-07.json +967 -0
  16. solvewright/interviewer/data/portal-samples/sample-08.json +967 -0
  17. solvewright/interviewer/interviewer.txt +113 -0
  18. solvewright/interviewer/references/case-generation.txt +52 -0
  19. solvewright/interviewer/references/curriculum.txt +21 -0
  20. solvewright/interviewer/references/scoring.txt +66 -0
  21. solvewright/interviewer/references/session-format.txt +60 -0
  22. solvewright/interviewer/tools/portal_lab.py +217 -0
  23. solvewright/runtime.py +673 -0
  24. solvewright/service.py +52 -0
  25. solvewright/settings.py +52 -0
  26. solvewright/worker.py +159 -0
  27. solvewright-0.1.0.dist-info/METADATA +132 -0
  28. solvewright-0.1.0.dist-info/RECORD +31 -0
  29. solvewright-0.1.0.dist-info/WHEEL +4 -0
  30. solvewright-0.1.0.dist-info/entry_points.txt +2 -0
  31. solvewright-0.1.0.dist-info/licenses/LICENSE +21 -0
solvewright/runtime.py ADDED
@@ -0,0 +1,673 @@
1
+ """Interview rounds on disk: the workspace, the examiner's files, and agent turns.
2
+
3
+ A round lives in one folder. The agent works with that folder as its working
4
+ directory; nothing it does is meant to reach outside it. Public tests run in
5
+ the candidate's browser, so this program never executes candidate code itself:
6
+ it only stores the files and the results the browser reports.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import threading
17
+ import time
18
+ import uuid
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path, PurePosixPath
21
+
22
+ from . import agents
23
+
24
+ SYNC_FORMAT = "solvewright-interview-sync-v1"
25
+ SYNC_LIMIT = 5_000_000
26
+ PORTABLE_STATE = ("id", "at", "updatedAt", "revision", "title", "caseFamily", "concepts", "settings", "messages", "stage",
27
+ "part", "parts", "partTimes", "finished", "testRuns", "error", "activity")
28
+ DEFAULT_SETTINGS = {"provider": "codex", "model": "", "effort": "", "style": "quant", "track": "hybrid", "mode": "mock", "minutes": 50,
29
+ "difficulty": "standard", "focus": "", "ai": "off", "case": "fresh", "language": "Python"}
30
+ # What each interview style asks of the examiner, written into session-settings.json.
31
+ STYLE_BRIEFS = {
32
+ "quant": ("A practical coding round in the style of quant firms such as HRT, Jane Street or Citadel: the candidate "
33
+ "designs and builds a small executable system (a simulator, a matching or routing engine, an event "
34
+ "processor with time semantics, a data structure under real load). Exactness and performance matter, "
35
+ "and the requirements change mid-round."),
36
+ "tech": ("A practical system-design coding round at a tech company: the candidate builds a working component of a "
37
+ "real system (a cache, a scheduler, a rate limiter, a storage or queueing layer) with clear interfaces and "
38
+ "tests, and the requirements change mid-round. Not a LeetCode puzzle."),
39
+ }
40
+ REMARK_REQUEST = ("The candidate asks for a re-mark. Re-read the whole record: the conversation, every snapshot in "
41
+ ".snapshots/ and .examiner/test-runs.json. Rewrite .examiner/report.json strictly from that evidence. "
42
+ "Change a mark only where the record supports it, and say in notes what changed and why.")
43
+ CHOICES = {"provider": ("codex", "claude"), "style": ("quant", "tech"), "track": ("hybrid", "research", "trading", "systems"),
44
+ "mode": ("mock", "coached", "drill"), "difficulty": ("foundation", "standard", "stretch"),
45
+ "ai": ("off", "assistant allowed", "audit AI-written code"), "case": ("fresh", "portals"),
46
+ "language": ("Python", "Pseudocode")}
47
+ PRIVATE_LOGS = (".examiner/claude-runtime.log", ".examiner/codex-runtime.log")
48
+ HISTORY_ROUNDS = 25
49
+ HISTORY_MESSAGE_CHARS = 3000
50
+
51
+
52
+ class SyncConflict(ValueError):
53
+ pass
54
+
55
+
56
+ def stamp() -> str:
57
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
58
+
59
+
60
+ def write_json(path: Path, data) -> None:
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ temporary = path.with_suffix(path.suffix + ".tmp")
63
+ temporary.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
64
+ temporary.replace(path)
65
+
66
+
67
+ def portable_path(name):
68
+ if not isinstance(name, str) or len(name) > 1000 or any(char in name for char in '\\:<>"|?*') or any(ord(char) < 32 for char in name):
69
+ raise ValueError("Invalid portable interview file path.")
70
+ parts = name.split("/")
71
+ if any(part in ("", ".", "..") or part.rstrip(" .") != part or re.fullmatch(r"(?i:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", part) for part in parts):
72
+ raise ValueError("Choose a safe, portable interview file path.")
73
+ if name != "session-settings.json" and (len(parts) < 2 or parts[0] not in ("workspace", ".examiner", ".snapshots")):
74
+ raise ValueError("Only interview workspace, examiner, settings and snapshot files can be restored.")
75
+ if name in PRIVATE_LOGS:
76
+ raise ValueError("Agent logs are not synchronized.")
77
+ return PurePosixPath(name)
78
+
79
+
80
+ def clean_settings(settings) -> dict:
81
+ if not isinstance(settings, dict):
82
+ raise ValueError("Choose the round's settings.")
83
+ chosen = {key: settings.get(key, default) for key, default in DEFAULT_SETTINGS.items()}
84
+ for key, values in CHOICES.items():
85
+ if chosen[key] not in values:
86
+ raise ValueError(f"Unsupported {key} for a new round.")
87
+ # The model name becomes one argument of the agent's command line: it must
88
+ # never be readable as an option, so it cannot start with a dash.
89
+ if not isinstance(chosen["model"], str) or len(chosen["model"]) > 100 or not re.fullmatch(r"(?:[A-Za-z0-9._:/@\[\]][A-Za-z0-9._:/@\[\]-]*)?", chosen["model"].strip()):
90
+ raise ValueError("Leave Model blank, or name one model.")
91
+ chosen["model"] = chosen["model"].strip()
92
+ if not isinstance(chosen["effort"], str) or not re.fullmatch(r"[a-z]{0,12}", chosen["effort"]):
93
+ raise ValueError("Choose one of the efforts your agent offers, or leave it to the agent.")
94
+ if not isinstance(chosen["focus"], str) or len(chosen["focus"]) > 1000:
95
+ raise ValueError("Keep the focus under 1,000 characters.")
96
+ if type(chosen["minutes"]) is not int or not 5 <= chosen["minutes"] <= 120:
97
+ raise ValueError("Choose between 5 and 120 minutes.")
98
+ return chosen
99
+
100
+
101
+ def validate_report(report, state):
102
+ """Match the portable report contract before exposing it as scored history."""
103
+ def text(value, limit=4000, empty=False):
104
+ return isinstance(value, str) and len(value) <= limit and (empty or bool(value.strip()))
105
+
106
+ def number(value, low, high):
107
+ return type(value) in (int, float) and low <= value <= high
108
+
109
+ def strings(value):
110
+ return isinstance(value, list) and len(value) <= 100 and all(text(item) for item in value)
111
+
112
+ if not isinstance(report, dict) or report.get("format") != "interview-lab-session-v1":
113
+ raise ValueError("The interviewer report is not an Interview Lab session record.")
114
+ if report.get("id") != state["id"] or report.get("at") != state["at"]:
115
+ raise ValueError("The interviewer report must use this round's session ID and starting timestamp.")
116
+ for field in ("caseId", "caseFamily", "title"):
117
+ if not text(report.get(field), 200):
118
+ raise ValueError(f"The interviewer report has an invalid {field}.")
119
+ choices = {"track": ("hybrid", "research", "trading", "systems"), "mode": ("mock", "coached", "drill"),
120
+ "difficulty": ("foundation", "standard", "stretch"), "novelty": ("unseen", "seen", "transfer"),
121
+ "status": ("completed", "time-limited", "stopped"),
122
+ "verification": ("executed", "inspection", "hand-trace", "discussion", "mixed")}
123
+ for field, values in choices.items():
124
+ if report.get(field) not in values:
125
+ raise ValueError(f"The interviewer report has an invalid {field}.")
126
+ if not number(report.get("plannedMinutes"), 1, 240) or (report.get("actualMinutes") is not None and not number(report.get("actualMinutes"), 0, 1440)):
127
+ raise ValueError("The interviewer report has invalid timing fields.")
128
+ if "actualMinutes" not in report:
129
+ raise ValueError("The interviewer report must include actualMinutes, or null when unknown.")
130
+ dimensions = report.get("dimensions")
131
+ for field in ("clarification", "modeling", "implementation", "validation", "adaptation", "communication", "tools"):
132
+ entry = dimensions.get(field) if isinstance(dimensions, dict) else None
133
+ if not isinstance(entry, dict) or "score" not in entry:
134
+ raise ValueError(f"The interviewer report is missing the {field} assessment.")
135
+ score = entry["score"]
136
+ if score is not None and (not number(score, 0, 4) or int(score) != score):
137
+ raise ValueError(f"The interviewer report has an invalid {field} score.")
138
+ if entry.get("assistance") not in ("none", "hint", "revealed", "ai") or not text(entry.get("evidence"), empty=score is None):
139
+ raise ValueError(f"The interviewer report has invalid {field} evidence or assistance.")
140
+ hints = report.get("hints")
141
+ if not isinstance(hints, list) or len(hints) > 100 or any(not isinstance(hint, dict) or not text(hint.get("stage"), 200) or not number(hint.get("level"), 1, 4) or int(hint["level"]) != hint["level"] or not text(hint.get("summary")) for hint in hints):
142
+ raise ValueError("The interviewer report has invalid hints.")
143
+ if any(not strings(report.get(field)) for field in ("reveals", "skips", "strengths", "gaps")) or not text(report.get("nextDrill"), empty=True) or not text(report.get("notes"), 8000, empty=True):
144
+ raise ValueError("The interviewer report is missing its debrief fields.")
145
+ verdict = report.get("verdict")
146
+ if verdict is not None and (not isinstance(verdict, dict) or not text(verdict.get("label"), 160) or verdict.get("meetsBar") not in (True, False, None)):
147
+ raise ValueError("The interviewer report has an invalid verdict.")
148
+ return report
149
+
150
+
151
+ def clean_test_run(run) -> dict:
152
+ if not isinstance(run, dict) or type(run.get("exitCode")) is not int or type(run.get("timedOut")) is not bool or not isinstance(run.get("output", ""), str):
153
+ raise ValueError("Send the browser's test result with an exit code.")
154
+ tests = run.get("tests", [])
155
+ if not isinstance(tests, list) or len(tests) > 500:
156
+ raise ValueError("Too many test rows.")
157
+ rows = []
158
+ for row in tests[:200]:
159
+ if not isinstance(row, dict) or row.get("outcome") not in ("passed", "failed", "skipped") or not isinstance(row.get("name"), str):
160
+ raise ValueError("Invalid test row.")
161
+ rows.append({"name": row["name"][:300], "file": str(row.get("file", ""))[:300], "outcome": row["outcome"],
162
+ "message": str(row.get("message", ""))[:2000]})
163
+ return {"at": stamp(), "exitCode": run["exitCode"], "timedOut": run["timedOut"], "output": run.get("output", "")[-30000:],
164
+ "tests": rows, "runner": "browser"}
165
+
166
+
167
+ class Runtime:
168
+ def __init__(self, root: Path, instructions: Path, logs: Path):
169
+ self.root, self.instructions, self.logs = root.resolve(), instructions.resolve(), logs.resolve()
170
+ if self.root.is_relative_to(self.instructions):
171
+ raise ValueError("Keep interview history outside the connector's own files.")
172
+ self.root.mkdir(parents=True, exist_ok=True)
173
+ self.lock = threading.RLock()
174
+ self.codex: agents.CodexClient | None = None
175
+ self.codex_lock = threading.Lock()
176
+ self.claude_processes: dict[str, subprocess.Popen] = {}
177
+ self.cancellations: dict[str, threading.Event] = {}
178
+ self.read_roots: list[str] | None = None
179
+ self.isolation = {"claude": "sandbox" if agents.claude_can_sandbox() else "no-commands", "codex": "sandbox"}
180
+ self.on_event = None
181
+ for path in self.root.glob("*/sessions/*/state.json"):
182
+ data = json.loads(path.read_text(encoding="utf-8"))
183
+ if data.get("busy"):
184
+ data.update(busy=False, error="The connector stopped during this turn. Your work was saved; send a message to continue.")
185
+ data.update(updatedAt=stamp(), revision=str(uuid.uuid4()))
186
+ write_json(path, data)
187
+ elif not data.get("revision"):
188
+ data.update(updatedAt=data.get("at", stamp()), revision=str(uuid.uuid4()))
189
+ write_json(path, data)
190
+
191
+ def event(self, text: str) -> None:
192
+ if self.on_event:
193
+ self.on_event(text)
194
+
195
+ def scope_root(self, scope: str) -> Path:
196
+ return self.root / hashlib.sha256(scope.encode()).hexdigest()[:20]
197
+
198
+ def directory(self, scope: str, session_id) -> Path:
199
+ if not isinstance(session_id, str) or not re.fullmatch(r"[a-f0-9-]{36}", session_id):
200
+ raise ValueError("Invalid round ID.")
201
+ return self.scope_root(scope) / "sessions" / session_id
202
+
203
+ def load(self, scope, session_id) -> dict:
204
+ path = self.directory(scope, session_id) / "state.json"
205
+ if not path.is_file():
206
+ raise ValueError("That round was not found on this computer.")
207
+ return json.loads(path.read_text(encoding="utf-8"))
208
+
209
+ def save(self, scope, state) -> None:
210
+ state.update(updatedAt=stamp(), revision=str(uuid.uuid4()))
211
+ write_json(self.directory(scope, state["id"]) / "state.json", state)
212
+
213
+ def base_revision(self, directory: Path):
214
+ path = directory / ".sync.json"
215
+ return json.loads(path.read_text(encoding="utf-8")).get("baseRevision") if path.is_file() else None
216
+
217
+ def copy_context(self, directory: Path) -> None:
218
+ target = directory / "context" / "interviewer"
219
+ shutil.copytree(self.instructions, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), dirs_exist_ok=True)
220
+
221
+ def portable_files(self, directory: Path) -> list[dict]:
222
+ files, size = [], 0
223
+ paths = [directory / "session-settings.json"]
224
+ for folder in ("workspace", ".examiner", ".snapshots"):
225
+ paths.extend(sorted((directory / folder).rglob("*")))
226
+ for path in paths:
227
+ if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(directory.resolve()):
228
+ continue
229
+ name = path.relative_to(directory).as_posix()
230
+ if name in PRIVATE_LOGS or path.suffix == ".pyc" or "__pycache__" in path.parts:
231
+ continue
232
+ portable_path(name)
233
+ size += path.stat().st_size
234
+ if size > SYNC_LIMIT:
235
+ raise ValueError("This round exceeds the 5 MB account-history limit. Its full copy stays on this computer.")
236
+ content = path.read_bytes()
237
+ try:
238
+ files.append({"path": name, "content": content.decode("utf-8")})
239
+ except UnicodeError:
240
+ files.append({"path": name, "content": base64.b64encode(content).decode("ascii"), "encoding": "base64"})
241
+ return files
242
+
243
+ def export_sync(self, scope, ids=None, metadata=False) -> dict:
244
+ if ids is not None and (not isinstance(ids, list) or len(ids) > 1000 or any(not isinstance(item, str) or not re.fullmatch(r"[a-f0-9-]{36}", item) for item in ids)):
245
+ raise ValueError("Choose valid round IDs to export.")
246
+ selected = set(ids) if ids is not None else None
247
+ with self.lock:
248
+ sessions, pending = [], False
249
+ for path in sorted(self.scope_root(scope).glob("sessions/*/state.json")):
250
+ state = json.loads(path.read_text(encoding="utf-8"))
251
+ if selected is not None and state["id"] not in selected:
252
+ continue
253
+ if state.get("busy"):
254
+ pending = True
255
+ continue
256
+ bundle = {"id": state["id"], "revision": state["revision"], "baseRevision": self.base_revision(path.parent),
257
+ "state": {"title": state.get("title", "Saved round")} if metadata else {key: state[key] for key in PORTABLE_STATE if key in state}}
258
+ if not metadata:
259
+ bundle["files"] = self.portable_files(path.parent)
260
+ if len(json.dumps(bundle).encode("utf-8")) > SYNC_LIMIT:
261
+ raise ValueError("This round exceeds the 5 MB account-history limit. Its full copy stays on this computer.")
262
+ sessions.append(bundle)
263
+ return {"format": SYNC_FORMAT, "sessions": sessions, "pendingBusy": pending}
264
+
265
+ def import_sync(self, scope, body) -> dict:
266
+ if not isinstance(body, dict) or body.get("format", SYNC_FORMAT) != SYNC_FORMAT or not isinstance(body.get("sessions", []), list) or not isinstance(body.get("acknowledgements", []), list):
267
+ raise ValueError("Invalid account interview history.")
268
+ if len(json.dumps(body).encode("utf-8")) > SYNC_LIMIT:
269
+ raise ValueError("Restore one round at a time; this request exceeds 5 MB.")
270
+ prepared, acknowledgements, seen = [], [], set()
271
+ for bundle in body.get("sessions", []):
272
+ if not isinstance(bundle, dict) or not isinstance(bundle.get("state"), dict) or not isinstance(bundle.get("files"), list):
273
+ raise ValueError("Invalid portable interview round.")
274
+ session_id, revision = bundle.get("id"), bundle.get("revision")
275
+ if not isinstance(session_id, str) or not re.fullmatch(r"[a-f0-9-]{36}", session_id) or session_id in seen or not isinstance(revision, str) or not re.fullmatch(r"[a-zA-Z0-9_-]{1,160}", revision):
276
+ raise ValueError("Invalid or duplicate portable round identity.")
277
+ seen.add(session_id)
278
+ state = {key: bundle["state"][key] for key in PORTABLE_STATE if key in bundle["state"]}
279
+ if state.get("id") != session_id or state.get("revision") != revision or any(not isinstance(state.get(field), str) for field in ("at", "updatedAt", "title", "caseFamily", "stage", "error", "activity")):
280
+ raise ValueError("Invalid portable round state.")
281
+ if not isinstance(state.get("settings"), dict) or state["settings"].get("provider") not in ("codex", "claude") or not isinstance(state["settings"].get("model", ""), str) or type(state.get("finished")) is not bool or not isinstance(state.get("concepts"), list) or any(not isinstance(item, str) for item in state["concepts"]):
282
+ raise ValueError("Invalid portable interview settings or stage.")
283
+ for field in ("at", "updatedAt"):
284
+ try:
285
+ if datetime.fromisoformat(state[field].replace("Z", "+00:00")).tzinfo is None:
286
+ raise ValueError()
287
+ except ValueError:
288
+ raise ValueError("Portable round dates need an ISO timestamp with a timezone.")
289
+ if not isinstance(state.get("messages"), list) or any(not isinstance(item, dict) or item.get("role") not in ("user", "assistant") or not isinstance(item.get("text"), str) or not isinstance(item.get("at"), str) for item in state["messages"]):
290
+ raise ValueError("Invalid portable interview conversation.")
291
+ if not isinstance(state.get("testRuns"), list) or any(not isinstance(item, dict) or type(item.get("exitCode")) is not int or type(item.get("timedOut")) is not bool or not isinstance(item.get("output"), str) or not isinstance(item.get("at"), str) for item in state["testRuns"]):
292
+ raise ValueError("Invalid portable test history.")
293
+ files, names = [], set()
294
+ for item in bundle["files"]:
295
+ if not isinstance(item, dict) or not isinstance(item.get("content"), str):
296
+ raise ValueError("Invalid portable interview file.")
297
+ name = portable_path(item.get("path"))
298
+ if name.as_posix().casefold() in names:
299
+ raise ValueError("A portable round contains duplicate file paths.")
300
+ names.add(name.as_posix().casefold())
301
+ if item.get("encoding") == "base64":
302
+ try:
303
+ content = base64.b64decode(item["content"], validate=True)
304
+ except ValueError:
305
+ raise ValueError("Invalid binary interview file.")
306
+ elif item.get("encoding") in (None, "utf-8"):
307
+ content = item["content"].encode("utf-8")
308
+ else:
309
+ raise ValueError("Unsupported portable interview file encoding.")
310
+ files.append((name, content))
311
+ if any(parent.as_posix().casefold() in names for name, _content in files for parent in name.parents if parent != PurePosixPath(".")):
312
+ raise ValueError("A portable round uses the same path as a file and a folder.")
313
+ state["busy"] = False
314
+ prepared.append((session_id, revision, bundle.get("expectedLocalRevision"), state, files))
315
+ for ack in body.get("acknowledgements", []):
316
+ if not isinstance(ack, dict) or not isinstance(ack.get("id"), str) or not isinstance(ack.get("revision"), str) or not re.fullmatch(r"[a-zA-Z0-9_-]{1,160}", ack["revision"]):
317
+ raise ValueError("Invalid account history acknowledgement.")
318
+ if ack["id"] in seen:
319
+ raise ValueError("Restore or acknowledge a round once per synchronization request.")
320
+ seen.add(ack["id"])
321
+ self.directory(scope, ack["id"])
322
+ acknowledgements.append(ack)
323
+ imported, skipped, acknowledged = [], [], []
324
+ with self.lock:
325
+ for session_id, _revision, expected, _state, _files in prepared:
326
+ directory = self.directory(scope, session_id)
327
+ local = self.load(scope, session_id) if (directory / "state.json").is_file() else None
328
+ if local and local.get("busy"):
329
+ skipped.append(session_id)
330
+ elif (local or {}).get("revision") != expected:
331
+ raise SyncConflict("This round changed on this computer during the restore. Both copies were kept; sync again.")
332
+ for ack in acknowledgements:
333
+ directory = self.directory(scope, ack["id"])
334
+ self.load(scope, ack["id"])
335
+ if self.base_revision(directory) != ack.get("expectedBaseRevision"):
336
+ raise SyncConflict("The account acknowledgement is out of date. Sync again; local work was kept.")
337
+ for session_id, revision, _expected, state, files in prepared:
338
+ if session_id in skipped:
339
+ continue
340
+ directory = self.directory(scope, session_id)
341
+ staging = directory.with_name(".import-" + str(uuid.uuid4()))
342
+ backup = directory.with_name(".prior-" + str(uuid.uuid4()))
343
+ try:
344
+ (staging / "workspace" / "tests").mkdir(parents=True)
345
+ (staging / ".examiner").mkdir()
346
+ for name, content in files:
347
+ target = staging.joinpath(*name.parts)
348
+ target.parent.mkdir(parents=True, exist_ok=True)
349
+ target.write_bytes(content)
350
+ self.copy_context(staging)
351
+ write_json(staging / "state.json", state)
352
+ write_json(staging / "session-settings.json", {**state["settings"], "id": session_id, "at": state["at"]})
353
+ write_json(staging / ".sync.json", {"baseRevision": revision})
354
+ if directory.exists():
355
+ directory.rename(backup)
356
+ try:
357
+ staging.rename(directory)
358
+ except OSError:
359
+ if backup.exists():
360
+ backup.rename(directory)
361
+ raise
362
+ imported.append(session_id)
363
+ finally:
364
+ shutil.rmtree(staging, ignore_errors=True)
365
+ if directory.exists():
366
+ shutil.rmtree(backup, ignore_errors=True)
367
+ for session_id in imported:
368
+ write_json(self.directory(scope, session_id) / "history-context.json", self.history_context(scope, session_id))
369
+ for ack in acknowledgements:
370
+ write_json(self.directory(scope, ack["id"]) / ".sync.json", {"baseRevision": ack["revision"]})
371
+ acknowledged.append(ack["id"])
372
+ return {"imported": imported, "skipped": skipped, "acknowledged": acknowledged}
373
+
374
+ def candidate_files(self, directory: Path) -> list[dict]:
375
+ result = []
376
+ workspace = (directory / "workspace").resolve()
377
+ for path in sorted(workspace.rglob("*")):
378
+ if any(part.startswith(".") for part in path.relative_to(workspace).parts):
379
+ continue
380
+ if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(workspace):
381
+ continue
382
+ if "__pycache__" in path.parts or path.stat().st_size > 500_000 or path.suffix in (".pyc", ".bin"):
383
+ continue
384
+ try:
385
+ result.append({"path": path.relative_to(workspace).as_posix(), "content": path.read_text(encoding="utf-8")})
386
+ except UnicodeError:
387
+ continue
388
+ return result
389
+
390
+ def public(self, scope, state, files=False) -> dict:
391
+ data = {key: value for key, value in state.items() if key not in ("agentSessionId", "activeTurn")}
392
+ directory = self.directory(scope, state["id"])
393
+ data["baseRevision"] = self.base_revision(directory)
394
+ if files:
395
+ data["files"] = self.candidate_files(directory)
396
+ report = directory / ".examiner" / "report.json"
397
+ if state.get("finished"):
398
+ try:
399
+ if not report.is_file():
400
+ raise ValueError("No assessment report was saved for this round.")
401
+ data["report"] = validate_report(json.loads(report.read_text(encoding="utf-8")), state)
402
+ except (ValueError, OSError) as error:
403
+ data["reportError"] = str(error) + " Send a message to ask the interviewer to repair the report."
404
+ return data
405
+
406
+ def sessions(self, scope) -> list[dict]:
407
+ rounds = [self.public(scope, json.loads(path.read_text(encoding="utf-8"))) for path in self.scope_root(scope).glob("sessions/*/state.json")]
408
+ return sorted(rounds, key=lambda item: item["at"], reverse=True)
409
+
410
+ def history_context(self, scope, exclude: str) -> dict:
411
+ """Previous rounds, inside this round's folder: the agent cannot read the others."""
412
+ prior = []
413
+ for item in [round_ for round_ in self.sessions(scope) if round_["id"] != exclude][:HISTORY_ROUNDS]:
414
+ entry = {key: item.get(key) for key in ("id", "at", "title", "caseFamily", "concepts", "settings", "stage", "finished", "report")}
415
+ entry["messages"] = [{**message, "text": message["text"][:HISTORY_MESSAGE_CHARS]} for message in item.get("messages", [])[-40:]]
416
+ entry["testRuns"] = [{key: run.get(key) for key in ("at", "exitCode", "timedOut")} for run in item.get("testRuns", [])[-10:]]
417
+ manifest = self.directory(scope, item["id"]) / ".examiner" / "manifest.json"
418
+ if manifest.is_file():
419
+ try:
420
+ entry["privateManifest"] = json.loads(manifest.read_text(encoding="utf-8"))
421
+ except ValueError:
422
+ pass
423
+ prior.append(entry)
424
+ return {"sessions": prior}
425
+
426
+ def create(self, scope, settings) -> dict:
427
+ settings = clean_settings(settings)
428
+ agents.provider_executable("claude" if settings["provider"] == "claude" else "codex")
429
+ with self.lock:
430
+ if any(item.get("busy") for item in self.sessions(scope)):
431
+ raise ValueError("Finish or stop the interviewer's current turn before starting another round.")
432
+ session_id = str(uuid.uuid4())
433
+ directory = self.directory(scope, session_id)
434
+ (directory / "workspace" / "tests").mkdir(parents=True)
435
+ (directory / ".examiner").mkdir()
436
+ self.copy_context(directory)
437
+ state = {"id": session_id, "at": stamp(), "title": "Preparing a fresh round", "caseFamily": "", "concepts": [],
438
+ "settings": settings, "messages": [], "stage": "Preparing", "busy": False, "finished": False,
439
+ "testRuns": [], "error": "", "activity": "Designing and checking the exercise"}
440
+ write_json(directory / "history-context.json", self.history_context(scope, session_id))
441
+ write_json(directory / "session-settings.json", {**settings, "styleBrief": STYLE_BRIEFS[settings["style"]], "id": session_id, "at": state["at"]})
442
+ self.save(scope, state)
443
+ self.start_turn(scope, session_id, "Start a new live runtime interview. Read context/interviewer/interviewer.txt, "
444
+ "context/interviewer/references/case-generation.txt and context/interviewer/references/curriculum.txt. "
445
+ "Read history-context.json and session-settings.json. Generate a fresh problem and a verified setup; "
446
+ "do not select from a catalogue. Use the reported portal case only if settings.case is portals. Create the "
447
+ "manifest and private reference, verify it, and publish candidate files before asking the opening "
448
+ "task. Return the required structured reply. Do not solve candidate work or expose future stages.",
449
+ initial=True)
450
+ return self.public(scope, self.load(scope, session_id), True)
451
+
452
+ def start_turn(self, scope, session_id, message, initial=False, remark=False) -> None:
453
+ with self.lock:
454
+ state = self.load(scope, session_id)
455
+ if state["busy"]:
456
+ raise ValueError("The interviewer is still working. Stop it or wait for its reply.")
457
+ if remark and not state["finished"]:
458
+ raise ValueError("A round is re-marked only after it has ended.")
459
+ if state["finished"] and not remark and "reportError" not in self.public(scope, state):
460
+ raise ValueError("This round has ended. Start a fresh round to keep practising.")
461
+ directory = self.directory(scope, session_id)
462
+ if not initial:
463
+ state["messages"].append({"role": "user", "text": message, "at": stamp()})
464
+ snapshot = directory / ".snapshots" / f"{len(state['messages']):04d}"
465
+ for file in self.candidate_files(directory):
466
+ target = snapshot / file["path"]
467
+ target.parent.mkdir(parents=True, exist_ok=True)
468
+ target.write_text(file["content"], encoding="utf-8")
469
+ state.update(busy=True, error="", activity="Designing and checking a fresh exercise" if initial else "Re-marking from the record" if remark else "Reading your work and preparing a reply")
470
+ self.save(scope, state)
471
+ self.cancellations[session_id] = threading.Event()
472
+ threading.Thread(target=self._turn, args=(scope, session_id, message, initial, remark), daemon=True).start()
473
+
474
+ def _turn(self, scope, session_id, message, initial, remark=False) -> None:
475
+ state = self.load(scope, session_id)
476
+ directory = self.directory(scope, session_id)
477
+ cancelled = self.cancellations.get(session_id)
478
+ repairing_report = bool(state["finished"])
479
+ provider = state["settings"]["provider"]
480
+ self.event(("Preparing a new round" if initial else "Interviewer turn") + " · " + ("Claude Code" if provider == "claude" else "Codex"))
481
+
482
+ def checkpoint():
483
+ with self.lock:
484
+ self.save(scope, state)
485
+
486
+ prompt = message if initial else (
487
+ "Live runtime continuation. Read the current files in workspace/ and .examiner/test-runs.json if present. "
488
+ "Preserve candidate code; change it only on an explicit assistance request. Continue the fixed case and stage "
489
+ "from your private manifest. Candidate message:\n\n" + message + "\n\nFor end/debrief write .examiner/report.json "
490
+ "using the session ID/date in session-settings.json and the scoring and session format references. Return the "
491
+ "structured runtime reply only.")
492
+ if repairing_report:
493
+ prompt = ("This round is already over. Repair its missing or invalid .examiner/report.json from the saved evidence, "
494
+ "without continuing scored work or changing candidate code. Keep finished true. Candidate request:\n\n" + message)
495
+ if remark:
496
+ prompt = REMARK_REQUEST + " Keep finished true and do not continue the interview. Candidate's note:\n\n" + message
497
+ if agents.WINDOWS:
498
+ prompt = f"This computer runs native Windows. Run Python with {agents.python_command()} instead of python3.\n\n" + prompt
499
+ if not initial and not state.get("agentSessionId"):
500
+ prompt = ("Resume this saved interview in a fresh agent conversation. Read context/interviewer/interviewer.txt, "
501
+ "state.json for the full prior conversation, session-settings.json, .examiner/manifest.json and "
502
+ ".examiner/progress.json if present, and the current workspace. Preserve the disclosed case and stage, "
503
+ "assistance history and candidate work. Do not generate a new exercise or reveal future stages.\n\n" + prompt)
504
+ try:
505
+ if cancelled and cancelled.is_set():
506
+ raise ValueError("The interviewer turn was stopped. Send a message to continue.")
507
+ if provider == "codex":
508
+ with self.codex_lock:
509
+ if self.codex is None or self.codex.closed.is_set() or self.codex.process.poll() is not None:
510
+ if self.codex:
511
+ self.codex.close()
512
+ self.codex = agents.CodexClient(self.logs, self.root)
513
+ reply = self.codex.turn(state, directory, prompt, checkpoint, cancelled)
514
+ self.isolation["codex"] = self.codex.isolation
515
+ else:
516
+ reply = self.claude_turn(state, directory, prompt, checkpoint, cancelled)
517
+ if not isinstance(reply, dict) or any(not isinstance(reply.get(field), str) for field in ("message", "title", "caseFamily", "stage")) or type(reply.get("finished")) is not bool or not isinstance(reply.get("concepts"), list) or any(not isinstance(concept, str) for concept in reply["concepts"]):
518
+ raise ValueError("The agent did not return a complete interview reply. Your workspace was kept; ask it to continue.")
519
+ for field in ("title", "caseFamily", "concepts", "stage", "finished"):
520
+ state[field] = reply[field]
521
+ for field in ("part", "parts"):
522
+ if type(reply.get(field)) is int and 0 <= reply[field] <= 20:
523
+ state[field] = reply[field]
524
+ if type(state.get("part")) is int and state["part"] >= 1:
525
+ # When each part was first shown: the header draws its real length.
526
+ state.setdefault("partTimes", {}).setdefault(str(state["part"]), stamp())
527
+ if repairing_report:
528
+ state["finished"] = True
529
+ state["messages"].append({"role": "assistant", "text": reply["message"], "at": stamp()})
530
+ state["activity"] = "Round complete" if state["finished"] else "Waiting for your response"
531
+ self.event(("Round ready · " + state["title"]) if initial else ("Round complete · " + state["title"]) if state["finished"] else "Interviewer replied")
532
+ except Exception as error: # Every failure becomes visible state, never a stuck turn.
533
+ state["error"] = str(error)
534
+ state["activity"] = "Agent turn stopped"
535
+ self.event("Turn stopped · " + str(error)[:160])
536
+ finally:
537
+ with self.lock:
538
+ state["busy"] = False
539
+ state.pop("activeTurn", None)
540
+ self.save(scope, state)
541
+ self.cancellations.pop(session_id, None)
542
+
543
+ def claude_turn(self, state, directory, prompt, checkpoint, cancelled=None) -> dict:
544
+ if cancelled and cancelled.is_set():
545
+ raise ValueError("The interviewer turn was stopped. Send a message to continue.")
546
+ binary = agents.provider_executable("claude")
547
+ if not agents.claude_supports_isolation(binary):
548
+ raise ValueError("This Claude Code is too old for the connector's sandboxed mode. Run `claude update`, then send another message.")
549
+ sandbox = agents.claude_can_sandbox()
550
+ if sandbox and self.read_roots is None:
551
+ self.read_roots = agents.python_read_roots()
552
+ command = agents.claude_command(binary, directory, state, sandbox, self.read_roots or [])
553
+ log = (directory / ".examiner" / "claude-runtime.log").open("a", encoding="utf-8")
554
+ process = None
555
+ result = None
556
+ try:
557
+ process = subprocess.Popen(command, cwd=directory, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log,
558
+ text=True, encoding="utf-8", env=agents.agent_environment(), bufsize=1,
559
+ **({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if agents.WINDOWS else {}))
560
+ self.claude_processes[state["id"]] = process
561
+ if cancelled and cancelled.is_set():
562
+ raise ValueError("The interviewer turn was stopped. Send a message to continue.")
563
+ process.stdin.write(agents.claude_preamble(sandbox) + prompt)
564
+ process.stdin.close()
565
+ for line in process.stdout:
566
+ try:
567
+ event = json.loads(line)
568
+ except ValueError:
569
+ continue
570
+ if event.get("session_id"):
571
+ state["agentSessionId"] = event["session_id"]
572
+ checkpoint()
573
+ if event.get("type") == "result":
574
+ result = event
575
+ process.wait()
576
+ if process.returncode or not result or result.get("is_error") or not result.get("structured_output"):
577
+ explanation = (result or {}).get("result") or "; ".join(str(item) for item in (result or {}).get("errors", [])) or "Claude Code did not return structured output. Check its sign-in with `claude` in a terminal."
578
+ raise ValueError(str(explanation)[:1500])
579
+ return result["structured_output"]
580
+ finally:
581
+ self.claude_processes.pop(state["id"], None)
582
+ if process:
583
+ if process.poll() is None:
584
+ process.terminate()
585
+ try:
586
+ process.wait(timeout=5)
587
+ except subprocess.TimeoutExpired:
588
+ process.kill()
589
+ process.wait()
590
+ for pipe in (process.stdin, process.stdout):
591
+ if pipe and not pipe.closed:
592
+ pipe.close()
593
+ log.close()
594
+
595
+ def _write_workspace(self, scope, session_id, files) -> dict:
596
+ state = self.load(scope, session_id)
597
+ if state["busy"]:
598
+ raise ValueError("Wait for the interviewer to finish before saving new work.")
599
+ workspace = (self.directory(scope, session_id) / "workspace").resolve()
600
+ if not isinstance(files, list) or len(files) > 100:
601
+ raise ValueError("Invalid candidate files.")
602
+ checked = []
603
+ for file in files:
604
+ if not isinstance(file, dict):
605
+ raise ValueError("Invalid candidate file.")
606
+ name, content = file.get("path"), file.get("content")
607
+ if not isinstance(name, str) or not Path(name).parts or "\\" in name or Path(name).is_absolute() or any(part.startswith(".") for part in Path(name).parts):
608
+ raise ValueError("Choose a visible workspace-relative file path.")
609
+ target = (workspace / name).resolve()
610
+ if not target.is_relative_to(workspace) or not isinstance(content, str) or len(content) > 500_000:
611
+ raise ValueError("Invalid workspace file.")
612
+ checked.append((target, content))
613
+ for target, content in checked:
614
+ target.parent.mkdir(parents=True, exist_ok=True)
615
+ target.write_text(content, encoding="utf-8")
616
+ return state
617
+
618
+ def save_files(self, scope, session_id, files) -> None:
619
+ with self.lock:
620
+ state = self._write_workspace(scope, session_id, files)
621
+ self.save(scope, state)
622
+
623
+ def record_test_run(self, scope, session_id, files, run) -> dict:
624
+ """Store a run the browser made on exactly these files, with a snapshot of them."""
625
+ result = clean_test_run(run)
626
+ with self.lock:
627
+ state = self._write_workspace(scope, session_id, files)
628
+ directory = self.directory(scope, session_id)
629
+ snapshot = directory / ".snapshots" / f"test-{len(state['testRuns']) + 1:04d}"
630
+ for file in self.candidate_files(directory):
631
+ target = snapshot / file["path"]
632
+ target.parent.mkdir(parents=True, exist_ok=True)
633
+ target.write_text(file["content"], encoding="utf-8")
634
+ result["snapshot"] = snapshot.relative_to(directory).as_posix()
635
+ state["testRuns"].append(result)
636
+ write_json(directory / ".examiner" / "test-runs.json", state["testRuns"])
637
+ self.save(scope, state)
638
+ passed = sum(row["outcome"] == "passed" for row in result["tests"])
639
+ self.event(f"Tests recorded · {passed} of {len(result['tests'])} passed")
640
+ return result
641
+
642
+ def codex_models(self) -> list[dict]:
643
+ """Codex's own model list, read through the same isolated app-server; cached ten minutes."""
644
+ cached = getattr(self, "_codex_models", None)
645
+ if cached and time.monotonic() - cached[0] < 600:
646
+ return cached[1]
647
+ with self.codex_lock:
648
+ if self.codex is None or self.codex.closed.is_set() or self.codex.process.poll() is not None:
649
+ if self.codex:
650
+ self.codex.close()
651
+ self.codex = agents.CodexClient(self.logs, self.root)
652
+ models = self.codex.models()
653
+ self._codex_models = (time.monotonic(), models)
654
+ return models
655
+
656
+ def interrupt(self, scope, session_id) -> None:
657
+ with self.lock:
658
+ state = self.load(scope, session_id)
659
+ cancelled = self.cancellations.get(session_id)
660
+ if cancelled:
661
+ cancelled.set()
662
+ if state["settings"]["provider"] == "codex" and self.codex:
663
+ self.codex.interrupt(state)
664
+ else:
665
+ process = self.claude_processes.get(session_id)
666
+ if process:
667
+ agents.interrupt_process(process)
668
+
669
+ def close(self) -> None:
670
+ if self.codex:
671
+ self.codex.close()
672
+ for process in list(self.claude_processes.values()):
673
+ process.terminate()