workjig 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.
workjig/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Run headless LLM coding harnesses as subprocess workers with durable results."""
2
+
3
+ from .models import CallResult, CommandWorker, LaunchRecord, Request, RetryPolicy
4
+ from .run import RecordError, Run, RunBusyError, UncertainLaunchError
5
+
6
+ __all__ = [
7
+ "LaunchRecord",
8
+ "CallResult",
9
+ "CommandWorker",
10
+ "RecordError",
11
+ "Request",
12
+ "RetryPolicy",
13
+ "Run",
14
+ "RunBusyError",
15
+ "UncertainLaunchError",
16
+ ]
workjig/adapters.py ADDED
@@ -0,0 +1,224 @@
1
+ """Version-scoped command templates and evidence extraction for headless CLIs.
2
+
3
+ Factories construct commands and declare their output protocols. They do not
4
+ authenticate, launch models, or claim that a requested model was the one served.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Literal
11
+
12
+ from .models import CommandWorker
13
+
14
+
15
+ def _required_text(value: str, name: str) -> str:
16
+ if not isinstance(value, str) or not value or any(char in value for char in "\x00{}\r\n"):
17
+ raise ValueError(f"{name} must be nonempty text without control or template characters")
18
+ return value
19
+
20
+
21
+ def _tool_list(tools: tuple[str, ...]) -> str:
22
+ if not tools or not all(
23
+ isinstance(tool, str) and tool and not any(char in tool for char in ",\x00{}\r\n")
24
+ for tool in tools
25
+ ):
26
+ raise ValueError("tools must be a nonempty tuple of names without commas")
27
+ return ",".join(tools)
28
+
29
+
30
+ _PROMPT_POINTER = "Read the complete task and file paths in `{prompt}`, then perform that task."
31
+
32
+
33
+ def codex_worker(
34
+ model: str,
35
+ *,
36
+ sandbox: Literal["read-only", "workspace-write", "danger-full-access"],
37
+ effort: str | None = None,
38
+ ) -> CommandWorker:
39
+ """Construct a Codex 0.162.1 noninteractive worker.
40
+
41
+ The caller chooses the sandbox. ``approval_policy=never`` denies requests
42
+ that require interactive approval; it does not replace host isolation.
43
+ """
44
+ _required_text(model, "model")
45
+ if sandbox not in {"read-only", "workspace-write", "danger-full-access"}:
46
+ raise ValueError("unsupported Codex sandbox")
47
+ argv = [
48
+ "codex",
49
+ "exec",
50
+ "--json",
51
+ "--skip-git-repo-check",
52
+ "-C",
53
+ "{workdir}",
54
+ "-m",
55
+ model,
56
+ "-s",
57
+ sandbox,
58
+ "-c",
59
+ "approval_policy=never",
60
+ ]
61
+ if effort is not None:
62
+ _required_text(effort, "effort")
63
+ argv.extend(("-c", f"model_reasoning_effort={effort}"))
64
+ argv.append(_PROMPT_POINTER)
65
+ return CommandWorker(argv=tuple(argv), name="codex", output_protocol="codex-json")
66
+
67
+
68
+ def claude_worker(
69
+ model: str,
70
+ *,
71
+ allowed_tools: tuple[str, ...],
72
+ permission_mode: Literal["dontAsk", "acceptEdits"] = "dontAsk",
73
+ ) -> CommandWorker:
74
+ """Construct a Claude Code 2.1.303 one-shot worker.
75
+
76
+ The caller selects an explicit tool allowlist. Unanswered permission
77
+ requests are denied; normal settings and instruction loading remain active.
78
+ """
79
+ _required_text(model, "model")
80
+ if permission_mode not in {"dontAsk", "acceptEdits"}:
81
+ raise ValueError("unsupported Claude permission mode")
82
+ tools = _tool_list(allowed_tools)
83
+ return CommandWorker(
84
+ argv=(
85
+ "claude",
86
+ "-p",
87
+ "--output-format",
88
+ "stream-json",
89
+ "--verbose",
90
+ "--model",
91
+ model,
92
+ "--permission-mode",
93
+ permission_mode,
94
+ "--permission-prompts",
95
+ "none",
96
+ f"--allowedTools={tools}",
97
+ _PROMPT_POINTER,
98
+ ),
99
+ name="claude",
100
+ output_protocol="claude-stream-json",
101
+ )
102
+
103
+
104
+ def pi_worker(
105
+ model: str,
106
+ *,
107
+ provider: str,
108
+ tools: tuple[str, ...],
109
+ project_trust: Literal["approve", "no-approve"],
110
+ ) -> CommandWorker:
111
+ """Construct a Pi 1.3.0 one-shot worker.
112
+
113
+ Project trust only controls project resource loading. Pi has no built-in
114
+ sandbox or per-tool approval; the caller supplies process isolation.
115
+ """
116
+ _required_text(model, "model")
117
+ _required_text(provider, "provider")
118
+ if project_trust not in {"approve", "no-approve"}:
119
+ raise ValueError("project_trust must be approve or no-approve")
120
+ return CommandWorker(
121
+ argv=(
122
+ "pi",
123
+ "--mode",
124
+ "json",
125
+ "--provider",
126
+ provider,
127
+ "--model",
128
+ model,
129
+ "--tools",
130
+ _tool_list(tools),
131
+ f"--{project_trust}",
132
+ "--session-dir",
133
+ "{launch_dir}",
134
+ _PROMPT_POINTER,
135
+ ),
136
+ name="pi",
137
+ output_protocol="pi-json",
138
+ )
139
+
140
+
141
+ def parse_evidence(harness: Literal["codex", "claude", "pi"], stdout: str) -> dict[str, object]:
142
+ """Extract documented fields from retained JSONL without guessing identity.
143
+
144
+ Raw stdout remains authoritative evidence. Parsing a partial stream yields
145
+ only fields actually present; callers retain parse_errors for diagnosis.
146
+ """
147
+ if harness not in {"codex", "claude", "pi"}:
148
+ raise ValueError("unknown harness")
149
+ records: list[dict[str, object]] = []
150
+ parse_errors = 0
151
+ # JSONL is LF-delimited. Unicode paragraph separators can occur in strings.
152
+ for line in stdout.split("\n"):
153
+ line = line.removesuffix("\r")
154
+ if not line.strip():
155
+ continue
156
+ try:
157
+ value = json.loads(line)
158
+ except json.JSONDecodeError:
159
+ parse_errors += 1
160
+ continue
161
+ if isinstance(value, dict):
162
+ records.append(value)
163
+ else:
164
+ parse_errors += 1
165
+
166
+ evidence: dict[str, object] = {
167
+ "harness": harness,
168
+ "records": len(records),
169
+ "parse_errors": parse_errors,
170
+ }
171
+ if harness == "codex":
172
+ error_events = 0
173
+ for record in records:
174
+ if record.get("type") == "thread.started" and isinstance(record.get("thread_id"), str):
175
+ evidence["session_id"] = record["thread_id"]
176
+ if record.get("type") in {"turn.completed", "turn.failed"}:
177
+ evidence["turn_outcome"] = record["type"]
178
+ if record.get("type") == "error":
179
+ error_events += 1
180
+ if error_events:
181
+ evidence["error_event_count"] = error_events
182
+ return evidence
183
+
184
+ if harness == "claude":
185
+ retry_errors: list[str] = []
186
+ for record in records:
187
+ if record.get("type") == "system" and record.get("subtype") == "api_retry":
188
+ if isinstance(record.get("error"), str):
189
+ retry_errors.append(record["error"])
190
+ if record.get("type") == "assistant":
191
+ message = record.get("message")
192
+ if isinstance(message, dict) and isinstance(message.get("model"), str):
193
+ evidence["reported_model"] = message["model"]
194
+ if record.get("type") == "result":
195
+ evidence["last_result_subtype"] = record.get("subtype")
196
+ if isinstance(record.get("modelUsage"), dict):
197
+ evidence["model_usage"] = record["modelUsage"]
198
+ if isinstance(record.get("session_id"), str):
199
+ evidence["session_id"] = record["session_id"]
200
+ if isinstance(record.get("is_error"), bool):
201
+ evidence["is_error"] = record["is_error"]
202
+ if retry_errors:
203
+ evidence["retry_error_categories"] = retry_errors
204
+ return evidence
205
+
206
+ for record in records:
207
+ if record.get("type") == "session" and isinstance(record.get("id"), str):
208
+ evidence["session_id"] = record["id"]
209
+ if record.get("type") == "message_end":
210
+ message = record.get("message")
211
+ if isinstance(message, dict) and message.get("role") == "assistant":
212
+ for source, target in (
213
+ ("provider", "provider"),
214
+ ("model", "reported_model"),
215
+ ("responseModel", "served_model"),
216
+ ("responseId", "response_id"),
217
+ ("stopReason", "stop_reason"),
218
+ ("errorMessage", "error_message"),
219
+ ):
220
+ if isinstance(message.get(source), str):
221
+ evidence[target] = message[source]
222
+ if record.get("type") == "agent_settled" and isinstance(record.get("aborted"), bool):
223
+ evidence["settled_aborted"] = record["aborted"]
224
+ return evidence
workjig/harness.py ADDED
@@ -0,0 +1,255 @@
1
+ """Per-launch file protocol and local process ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import time
10
+ from collections.abc import Callable, Mapping
11
+ from contextlib import ExitStack
12
+ from dataclasses import replace
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ from .models import LaunchRecord, Request
17
+ from .outcomes import terminal_failure
18
+
19
+
20
+ def _now() -> str:
21
+ return datetime.now(UTC).isoformat()
22
+
23
+
24
+ def prepare(
25
+ request: Request, launch_dir: Path, workdir: Path
26
+ ) -> tuple[Mapping[str, Path], tuple[str, ...]]:
27
+ """Write fresh inputs and a human-readable task frame before reservation."""
28
+ input_dir = launch_dir / "inputs"
29
+ output_dir = launch_dir / "outputs"
30
+ input_dir.mkdir()
31
+ output_dir.mkdir()
32
+ for name, content in request.inputs.items():
33
+ (input_dir / name).write_text(content, encoding="utf-8")
34
+ prompt = launch_dir / "prompt.txt"
35
+ lines = ["Task:", request.task, "", "Inputs (read these exact files):"]
36
+ lines.extend(f"- {name}: {input_dir / name}" for name in sorted(request.inputs))
37
+ lines.extend(["", "Required outputs (write these exact files):"])
38
+ lines.extend(f"- {name}: {output_dir / name}" for name in request.required_outputs)
39
+ if request.optional_outputs:
40
+ lines.extend(["", "Optional outputs:"])
41
+ lines.extend(f"- {name}: {output_dir / name}" for name in request.optional_outputs)
42
+ lines.extend(
43
+ [
44
+ "",
45
+ "If unable to finish, write a JSON object to this problem file:",
46
+ str(launch_dir / "problem.json"),
47
+ 'Use {"kind":"unable","message":"..."} or {"kind":"usage_limit","message":"..."}.',
48
+ ]
49
+ )
50
+ prompt.write_text("\n".join(lines) + "\n", encoding="utf-8")
51
+ paths = {
52
+ "prompt": prompt,
53
+ "input_dir": input_dir,
54
+ "output_dir": output_dir,
55
+ "problem_file": launch_dir / "problem.json",
56
+ "evidence_file": launch_dir / (request.worker.evidence_file or "evidence.json"),
57
+ "launch_dir": launch_dir,
58
+ "workdir": workdir,
59
+ }
60
+ return paths, request.worker.command(paths)
61
+
62
+
63
+ def _group_exists(pgid: int) -> bool:
64
+ try:
65
+ os.killpg(pgid, 0)
66
+ except ProcessLookupError:
67
+ return False
68
+ return True
69
+
70
+
71
+ def _cleanup_group(process: subprocess.Popen[bytes]) -> bool:
72
+ """Terminate descendants even if the leader has already exited."""
73
+ pgid = process.pid
74
+ process.poll()
75
+ if not _group_exists(pgid):
76
+ return False
77
+ try:
78
+ os.killpg(pgid, signal.SIGTERM)
79
+ except ProcessLookupError:
80
+ return False
81
+ deadline = time.monotonic() + 0.25
82
+ while time.monotonic() < deadline:
83
+ # Reap the leader so its zombie does not keep an empty group alive.
84
+ # Still inspect the group: surviving children need cleanup too.
85
+ process.poll()
86
+ if not _group_exists(pgid):
87
+ return True
88
+ time.sleep(0.01)
89
+ try:
90
+ os.killpg(pgid, signal.SIGKILL)
91
+ except ProcessLookupError:
92
+ pass
93
+ return True
94
+
95
+
96
+ def _read_problem(path: Path) -> tuple[dict[str, str] | None, bool]:
97
+ if path.is_symlink():
98
+ return None, True
99
+ if not path.exists():
100
+ return None, False
101
+ if not path.is_file():
102
+ return None, True
103
+ try:
104
+ value = json.loads(path.read_text(encoding="utf-8"))
105
+ except (OSError, UnicodeError, json.JSONDecodeError):
106
+ return None, True
107
+ if (
108
+ not isinstance(value, dict)
109
+ or not isinstance(value.get("kind"), str)
110
+ or value.get("kind") not in {"unable", "usage_limit"}
111
+ or not isinstance(value.get("message"), str)
112
+ ):
113
+ return None, True
114
+ return {"kind": value["kind"], "message": value["message"]}, False
115
+
116
+
117
+ def _read_evidence(path: Path) -> Mapping[str, object] | None:
118
+ if path.is_symlink() or not path.is_file():
119
+ return None
120
+ try:
121
+ value = json.loads(path.read_text(encoding="utf-8"))
122
+ # Optional metadata must fit the same strict JSON format as run records.
123
+ # This also rejects overflowing numeric literals and nested nonfinite values.
124
+ json.dumps(value, allow_nan=False)
125
+ except (OSError, UnicodeError, ValueError):
126
+ return None
127
+ return value if isinstance(value, dict) else None
128
+
129
+
130
+ def launch(
131
+ request: Request,
132
+ workdir: Path,
133
+ paths: Mapping[str, Path],
134
+ argv: tuple[str, ...],
135
+ reserved: LaunchRecord,
136
+ on_started: Callable[[int, tuple[str, ...], str], None],
137
+ ) -> tuple[LaunchRecord, dict[str, str]]:
138
+ """Invoke once, terminate its process group, then collect only its outputs."""
139
+ started_at = _now()
140
+ stdout_path = Path(reserved.launch_dir) / "stdout.log"
141
+ stderr_path = Path(reserved.launch_dir) / "stderr.log"
142
+ env = os.environ.copy()
143
+ env.update(request.worker.env)
144
+
145
+ def launch_error(error: OSError) -> tuple[LaunchRecord, dict[str, str]]:
146
+ return (
147
+ replace(
148
+ reserved,
149
+ status="launch_error",
150
+ argv=argv,
151
+ started_at=started_at,
152
+ finished_at=_now(),
153
+ problem={"kind": "launch_error", "message": str(error)},
154
+ ),
155
+ {},
156
+ )
157
+
158
+ files = ExitStack()
159
+ try:
160
+ stdout = files.enter_context(stdout_path.open("wb"))
161
+ stderr = files.enter_context(stderr_path.open("wb"))
162
+ except OSError as error:
163
+ files.close()
164
+ return launch_error(error)
165
+ try:
166
+ try:
167
+ process = subprocess.Popen(
168
+ argv,
169
+ cwd=workdir,
170
+ env=env,
171
+ stdin=subprocess.DEVNULL,
172
+ stdout=stdout,
173
+ stderr=stderr,
174
+ start_new_session=True,
175
+ )
176
+ except OSError as error:
177
+ return launch_error(error)
178
+ timed_out = False
179
+ group_lingered = False
180
+ try:
181
+ on_started(process.pid, argv, started_at)
182
+ process.wait(timeout=request.timeout_seconds)
183
+ except subprocess.TimeoutExpired:
184
+ timed_out = True
185
+ finally:
186
+ group_lingered = _cleanup_group(process)
187
+ if process.poll() is None:
188
+ process.wait(timeout=1)
189
+ finally:
190
+ files.close()
191
+
192
+ status = "delivered"
193
+ problem, invalid_problem = _read_problem(paths["problem_file"])
194
+ evidence = _read_evidence(paths["evidence_file"])
195
+ outputs: dict[str, str] = {}
196
+ missing: list[str] = []
197
+ # Keep emitted invariants consistent with run._attempt_from_dict; preserve
198
+ # compatibility with records produced by earlier classification rules.
199
+ harness_failure = (
200
+ terminal_failure(request.worker.output_protocol, stdout_path)
201
+ if request.worker.output_protocol is not None and not timed_out
202
+ else None
203
+ )
204
+ if timed_out:
205
+ status = "timeout"
206
+ elif harness_failure == "usage_limit" or (
207
+ problem is not None and problem["kind"] == "usage_limit"
208
+ ):
209
+ status = "usage_limit"
210
+ elif group_lingered:
211
+ status = "process_error"
212
+ elif invalid_problem:
213
+ status = "protocol_error"
214
+ elif problem is not None:
215
+ status = "worker_problem"
216
+ elif process.returncode != 0 or harness_failure is not None:
217
+ status = "process_error"
218
+ else:
219
+ if paths["output_dir"].is_symlink():
220
+ status = "protocol_error"
221
+ for name in request.required_outputs + request.optional_outputs:
222
+ if status != "delivered":
223
+ break
224
+ path = paths["output_dir"] / name
225
+ if path.is_symlink():
226
+ status = "protocol_error"
227
+ break
228
+ if not path.is_file():
229
+ if name in request.required_outputs:
230
+ missing.append(name)
231
+ continue
232
+ try:
233
+ outputs[name] = path.read_text(encoding="utf-8")
234
+ except (OSError, UnicodeError):
235
+ status = "protocol_error"
236
+ break
237
+ if status == "delivered" and missing:
238
+ status = "incomplete"
239
+ if status != "delivered":
240
+ outputs = {}
241
+ return (
242
+ replace(
243
+ reserved,
244
+ status=status,
245
+ argv=argv,
246
+ pid=process.pid,
247
+ returncode=process.returncode,
248
+ started_at=started_at,
249
+ finished_at=_now(),
250
+ problem=problem,
251
+ evidence=evidence,
252
+ missing_outputs=tuple(missing),
253
+ ),
254
+ outputs,
255
+ )