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 +16 -0
- workjig/adapters.py +224 -0
- workjig/harness.py +255 -0
- workjig/models.py +229 -0
- workjig/outcomes.py +135 -0
- workjig/run.py +727 -0
- workjig-0.1.0.dist-info/METADATA +171 -0
- workjig-0.1.0.dist-info/RECORD +11 -0
- workjig-0.1.0.dist-info/WHEEL +5 -0
- workjig-0.1.0.dist-info/licenses/LICENSE +21 -0
- workjig-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|