clink-cli 0.2.1__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.
- clink/__init__.py +19 -0
- clink/agents/__init__.py +30 -0
- clink/agents/base.py +70 -0
- clink/agents/claude.py +7 -0
- clink/agents/codex.py +7 -0
- clink/agents/gemini.py +7 -0
- clink/allowed_roots.py +356 -0
- clink/backend.py +765 -0
- clink/cli.py +594 -0
- clink/constants.py +78 -0
- clink/contracts.py +167 -0
- clink/files.py +70 -0
- clink/generation.py +274 -0
- clink/generation_capabilities.py +207 -0
- clink/generation_contracts.py +214 -0
- clink/generation_policy.py +174 -0
- clink/models.py +243 -0
- clink/native.py +409 -0
- clink/native_adapters.py +244 -0
- clink/native_contracts.py +68 -0
- clink/native_generation.py +421 -0
- clink/native_generation_claude_policy.py +59 -0
- clink/native_generation_contracts.py +264 -0
- clink/native_generation_families.py +218 -0
- clink/native_generation_family_policy.py +14 -0
- clink/native_generation_files.py +153 -0
- clink/native_generation_policy.py +82 -0
- clink/native_policy.py +86 -0
- clink/parsers/__init__.py +34 -0
- clink/parsers/agy.py +53 -0
- clink/parsers/agy_native.py +161 -0
- clink/parsers/agy_native_generation.py +189 -0
- clink/parsers/base.py +209 -0
- clink/parsers/claude.py +104 -0
- clink/parsers/claude_native.py +113 -0
- clink/parsers/claude_native_generation.py +346 -0
- clink/parsers/codex.py +172 -0
- clink/parsers/codex_generation.py +212 -0
- clink/parsers/codex_native.py +236 -0
- clink/parsers/codex_native_generation.py +367 -0
- clink/parsers/gemini.py +110 -0
- clink/parsers/grok.py +63 -0
- clink/parsers/grok_native.py +91 -0
- clink/parsers/grok_native_generation.py +157 -0
- clink/policy.py +180 -0
- clink/registry.py +544 -0
- clink/resources/CODEX-LICENSE +201 -0
- clink/resources/CODEX-NOTICE +6 -0
- clink/resources/PROVENANCE.md +16 -0
- clink/resources/codex-0.166.0-models-direct.json +1284 -0
- clink/resources/codex-0.166.0-models-generation.json +1269 -0
- clink/resources/native-agy-1.4.12-admission.json +746 -0
- clink/resources/native-claude-2.1.304-admission.json +99 -0
- clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
- clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
- clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
- clink/resources/native-grok-1.0.70-admission.json +644 -0
- clink/runner.py +303 -0
- clink/setup.py +399 -0
- clink/tasks.py +695 -0
- clink_cli-0.2.1.dist-info/METADATA +225 -0
- clink_cli-0.2.1.dist-info/RECORD +141 -0
- clink_cli-0.2.1.dist-info/WHEEL +5 -0
- clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
- clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
- clink_cli-0.2.1.dist-info/top_level.txt +7 -0
- conf/__init__.py +1 -0
- conf/cli_clients/agy.json +40 -0
- conf/cli_clients/claude.json +39 -0
- conf/cli_clients/codex.json +40 -0
- conf/cli_clients/gemini.json +23 -0
- conf/cli_clients/grok.json +40 -0
- config.py +40 -0
- server.py +896 -0
- systemprompts/__init__.py +35 -0
- systemprompts/analyze_prompt.py +87 -0
- systemprompts/chat_prompt.py +57 -0
- systemprompts/clink/codex_codereviewer.txt +8 -0
- systemprompts/clink/default.txt +8 -0
- systemprompts/clink/default_codereviewer.txt +8 -0
- systemprompts/clink/default_planner.txt +7 -0
- systemprompts/clink/native_codereviewer.txt +1 -0
- systemprompts/clink/native_default.txt +1 -0
- systemprompts/clink/native_planner.txt +1 -0
- systemprompts/codereview_prompt.py +91 -0
- systemprompts/consensus_prompt.py +120 -0
- systemprompts/debug_prompt.py +150 -0
- systemprompts/docgen_prompt.py +252 -0
- systemprompts/generate_code_prompt.py +181 -0
- systemprompts/planner_prompt.py +127 -0
- systemprompts/precommit_prompt.py +179 -0
- systemprompts/refactor_prompt.py +323 -0
- systemprompts/secaudit_prompt.py +420 -0
- systemprompts/testgen_prompt.py +133 -0
- systemprompts/thinkdeep_prompt.py +55 -0
- systemprompts/tracer_prompt.py +145 -0
- tools/__init__.py +43 -0
- tools/analyze.py +583 -0
- tools/apilookup.py +97 -0
- tools/challenge.py +198 -0
- tools/chat.py +187 -0
- tools/clink.py +130 -0
- tools/codereview.py +724 -0
- tools/consensus.py +790 -0
- tools/debug.py +573 -0
- tools/docgen.py +577 -0
- tools/listmodels.py +48 -0
- tools/models.py +373 -0
- tools/planner.py +533 -0
- tools/precommit.py +776 -0
- tools/refactor.py +645 -0
- tools/secaudit.py +685 -0
- tools/shared/__init__.py +19 -0
- tools/shared/base_models.py +167 -0
- tools/shared/base_tool.py +659 -0
- tools/shared/exceptions.py +20 -0
- tools/shared/schema_builders.py +159 -0
- tools/simple/__init__.py +18 -0
- tools/simple/base.py +539 -0
- tools/testgen.py +513 -0
- tools/thinkdeep.py +586 -0
- tools/tracer.py +763 -0
- tools/version.py +53 -0
- tools/workflow/__init__.py +22 -0
- tools/workflow/base.py +448 -0
- tools/workflow/schema_builders.py +166 -0
- tools/workflow/workflow_mixin.py +1195 -0
- utils/__init__.py +21 -0
- utils/call_state.py +349 -0
- utils/client_info.py +293 -0
- utils/consensus_manifest.py +131 -0
- utils/conversation_memory.py +582 -0
- utils/env.py +111 -0
- utils/file_types.py +271 -0
- utils/file_utils.py +803 -0
- utils/image_utils.py +94 -0
- utils/security_config.py +163 -0
- utils/storage_backend.py +177 -0
- utils/token_utils.py +54 -0
clink/tasks.py
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"""Server-owned native jobs with atomic deduplication and bounded volatile retention."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from clink.contracts import BackendError
|
|
19
|
+
from clink.native_contracts import FrozenNativeContext, NativeRunOutcome, NativeRuntime
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("clink.tasks")
|
|
22
|
+
MAX_TASKS = 64
|
|
23
|
+
MAX_ATTEMPTS = 16
|
|
24
|
+
MAX_RETAINED_BYTES = 8 * 1024 * 1024
|
|
25
|
+
RESULT_CHARACTERS = 20000
|
|
26
|
+
RESULT_METADATA_BYTES = 8192
|
|
27
|
+
# A JSON control character needs six bytes even when UTF-8 itself needs only one.
|
|
28
|
+
_COMPLETION_RESERVATION_BYTES = RESULT_CHARACTERS * 6 + RESULT_METADATA_BYTES + 4096
|
|
29
|
+
TERMINAL_TTL_SECONDS = 3 * 60 * 60
|
|
30
|
+
DEADLINE_SECONDS = 1800
|
|
31
|
+
PROMPT_BYTES = 1024 * 1024
|
|
32
|
+
_ACTIVE = frozenset({"queued", "running"})
|
|
33
|
+
_TERMINAL = frozenset({"completed", "permission_needed", "failed", "cancelled", "uncertain"})
|
|
34
|
+
_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}\Z")
|
|
35
|
+
_KEY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z")
|
|
36
|
+
_ERROR_CODES = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"authentication_required",
|
|
39
|
+
"cancelled",
|
|
40
|
+
"cli_missing",
|
|
41
|
+
"invalid_config",
|
|
42
|
+
"input_limit",
|
|
43
|
+
"missing_environment",
|
|
44
|
+
"policy_unverified",
|
|
45
|
+
"recursion_blocked",
|
|
46
|
+
"native_unavailable",
|
|
47
|
+
"native_error",
|
|
48
|
+
"native_session_mismatch",
|
|
49
|
+
"nonzero_exit",
|
|
50
|
+
"output_limit",
|
|
51
|
+
"parse_error",
|
|
52
|
+
"path_not_allowed",
|
|
53
|
+
"permission_needed",
|
|
54
|
+
"queue_full",
|
|
55
|
+
"queue_timeout",
|
|
56
|
+
"rate_limited",
|
|
57
|
+
"session_unavailable",
|
|
58
|
+
"timeout",
|
|
59
|
+
"unsupported_capability",
|
|
60
|
+
"unsupported_platform",
|
|
61
|
+
"verification_required",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
# Admission exceptions may contain private diagnostics; publish only code-indexed text.
|
|
65
|
+
_PREPARE_DIAGNOSTICS = {
|
|
66
|
+
"path_not_allowed": (
|
|
67
|
+
"Native project_dir and context paths must be absolute, accessible, and within the configured allowed_roots.",
|
|
68
|
+
"Supply project_dir and context paths within an allowed_roots entry; inspect listmodels for the selected "
|
|
69
|
+
"client's allowed_roots. To authorize another workspace, update allowed_roots in the operator-owned CLI "
|
|
70
|
+
"manifest using `clink allow /absolute/workspace --cli NAME --yes` (replace NAME with the selected CLI; "
|
|
71
|
+
"include `--project /bound/project` for a bound server), then restart the server.",
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
# Native output is untrusted. Public metadata must not become a second raw-stream channel.
|
|
75
|
+
_METADATA_KEYS = frozenset(
|
|
76
|
+
{
|
|
77
|
+
"actual_model",
|
|
78
|
+
"duration_seconds",
|
|
79
|
+
"duration_ms",
|
|
80
|
+
"duration_api_ms",
|
|
81
|
+
"num_turns",
|
|
82
|
+
"exit_status",
|
|
83
|
+
"stdout_bytes",
|
|
84
|
+
"stderr_bytes",
|
|
85
|
+
"queue_seconds",
|
|
86
|
+
"verification_status",
|
|
87
|
+
"configuration_sources",
|
|
88
|
+
"missing_environment_names",
|
|
89
|
+
"environment_names",
|
|
90
|
+
"permission_denial_count",
|
|
91
|
+
"tool_error_count",
|
|
92
|
+
"truncated",
|
|
93
|
+
"read_scope",
|
|
94
|
+
"repository_instructions",
|
|
95
|
+
"cleanup_scope",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _encoded_size(value: Any) -> int:
|
|
101
|
+
return len(json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str).encode("utf-8"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class _Attempt:
|
|
106
|
+
attempt_id: str
|
|
107
|
+
state: str
|
|
108
|
+
admitted_at: float
|
|
109
|
+
started_at: float | None = None
|
|
110
|
+
terminal_at: float | None = None
|
|
111
|
+
possible_partial_effects: bool = False
|
|
112
|
+
cancel_requested: bool = False
|
|
113
|
+
error_code: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class _TaskRecord:
|
|
118
|
+
task_handle: str
|
|
119
|
+
context: FrozenNativeContext
|
|
120
|
+
attempts: list[_Attempt] = field(default_factory=list)
|
|
121
|
+
session_id: str | None = None
|
|
122
|
+
last_terminal_at: float | None = None
|
|
123
|
+
latest_result: dict[str, Any] | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class _KeyEntry:
|
|
128
|
+
fingerprint: str
|
|
129
|
+
task_handle: str
|
|
130
|
+
attempt_id: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class NativeTaskManager:
|
|
134
|
+
"""Own each native input until cleanup; no prompt bodies or PAL history enter records."""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
runtime: NativeRuntime,
|
|
139
|
+
*,
|
|
140
|
+
clock: Callable[[], float] = time.monotonic,
|
|
141
|
+
max_tasks: int = MAX_TASKS,
|
|
142
|
+
max_attempts: int = MAX_ATTEMPTS,
|
|
143
|
+
max_retained_bytes: int = MAX_RETAINED_BYTES,
|
|
144
|
+
terminal_ttl: float = TERMINAL_TTL_SECONDS,
|
|
145
|
+
) -> None:
|
|
146
|
+
self.runtime = runtime
|
|
147
|
+
self._clock = clock
|
|
148
|
+
self.max_tasks = min(max_tasks, MAX_TASKS)
|
|
149
|
+
self.max_attempts = min(max_attempts, MAX_ATTEMPTS)
|
|
150
|
+
self.max_retained_bytes = min(max_retained_bytes, MAX_RETAINED_BYTES)
|
|
151
|
+
self.terminal_ttl = min(terminal_ttl, TERMINAL_TTL_SECONDS)
|
|
152
|
+
if min(self.max_tasks, self.max_attempts, self.max_retained_bytes, self.terminal_ttl) <= 0:
|
|
153
|
+
raise ValueError("Native task limits must be positive.")
|
|
154
|
+
self._records: dict[str, _TaskRecord] = {}
|
|
155
|
+
self._keys: dict[str, _KeyEntry] = {}
|
|
156
|
+
self._jobs: dict[str, asyncio.Task[None]] = {}
|
|
157
|
+
self._leases: dict[str, Path] = {}
|
|
158
|
+
self._session_owners: dict[tuple[str, str], str] = {}
|
|
159
|
+
self._lock = asyncio.Lock()
|
|
160
|
+
self._closed = False
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _validate(args: dict[str, Any], action: str) -> dict[str, Any]:
|
|
164
|
+
if not isinstance(args, dict):
|
|
165
|
+
raise BackendError("invalid_config", "Native task arguments must be an object.")
|
|
166
|
+
fields = {"action", "task_handle"}
|
|
167
|
+
if action in {"start", "followup"}:
|
|
168
|
+
fields |= {
|
|
169
|
+
"cli_name",
|
|
170
|
+
"role",
|
|
171
|
+
"model",
|
|
172
|
+
"project_dir",
|
|
173
|
+
"prompt",
|
|
174
|
+
"files",
|
|
175
|
+
"absolute_file_paths",
|
|
176
|
+
"images",
|
|
177
|
+
"request_key",
|
|
178
|
+
"timeout_seconds",
|
|
179
|
+
}
|
|
180
|
+
if set(args) - fields or (args.get("action") is not None and args["action"] != action):
|
|
181
|
+
raise BackendError("invalid_config", "Unsupported arguments for this native task action.")
|
|
182
|
+
value = dict(args)
|
|
183
|
+
value.pop("action", None)
|
|
184
|
+
if action != "start":
|
|
185
|
+
handle = value.get("task_handle")
|
|
186
|
+
if not isinstance(handle, str) or not re.fullmatch(r"nt_[a-f0-9]{32}", handle):
|
|
187
|
+
raise BackendError("invalid_config", "A valid native task handle is required.")
|
|
188
|
+
elif "task_handle" in value:
|
|
189
|
+
raise BackendError("invalid_config", "Start does not accept an existing task handle.")
|
|
190
|
+
if action not in {"start", "followup"}:
|
|
191
|
+
return value
|
|
192
|
+
if "absolute_file_paths" in value:
|
|
193
|
+
if "files" in value:
|
|
194
|
+
raise BackendError("invalid_config", "Supply native context paths through one field only.")
|
|
195
|
+
value["files"] = value.pop("absolute_file_paths")
|
|
196
|
+
timeout = value.get("timeout_seconds", DEADLINE_SECONDS)
|
|
197
|
+
try:
|
|
198
|
+
valid_timeout = (
|
|
199
|
+
not isinstance(timeout, bool)
|
|
200
|
+
and isinstance(timeout, (int, float))
|
|
201
|
+
and math.isfinite(timeout)
|
|
202
|
+
and 1 <= timeout <= DEADLINE_SECONDS
|
|
203
|
+
)
|
|
204
|
+
except OverflowError:
|
|
205
|
+
valid_timeout = False
|
|
206
|
+
if not valid_timeout:
|
|
207
|
+
raise BackendError("invalid_config", "Native timeout_seconds must be a finite number from 1 to 1800.")
|
|
208
|
+
value["timeout_seconds"] = float(timeout)
|
|
209
|
+
key = value.get("request_key")
|
|
210
|
+
if not isinstance(key, str) or not _KEY.fullmatch(key):
|
|
211
|
+
raise BackendError("invalid_config", "A request_key of 1 to 128 ASCII identifier characters is required.")
|
|
212
|
+
prompt = value.get("prompt")
|
|
213
|
+
try:
|
|
214
|
+
valid_prompt = (
|
|
215
|
+
isinstance(prompt, str) and bool(prompt.strip()) and len(prompt.encode("utf-8")) <= PROMPT_BYTES
|
|
216
|
+
)
|
|
217
|
+
except UnicodeEncodeError:
|
|
218
|
+
valid_prompt = False
|
|
219
|
+
if not valid_prompt:
|
|
220
|
+
raise BackendError("invalid_config", "A nonempty native task prompt within the 1 MiB limit is required.")
|
|
221
|
+
for name in ("cli_name", "role"):
|
|
222
|
+
selection = value.get(name)
|
|
223
|
+
if selection is not None and (not isinstance(selection, str) or not _NAME.fullmatch(selection)):
|
|
224
|
+
raise BackendError("invalid_config", "Native CLI and role names must be bounded identifiers.")
|
|
225
|
+
if action == "start" and not value.get("cli_name"):
|
|
226
|
+
raise BackendError("invalid_config", "Start requires explicit cli_name selection.")
|
|
227
|
+
model = value.get("model")
|
|
228
|
+
if model is not None and (
|
|
229
|
+
not isinstance(model, str)
|
|
230
|
+
or not model
|
|
231
|
+
or model != model.strip()
|
|
232
|
+
or len(model) > 256
|
|
233
|
+
or model.startswith("-")
|
|
234
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in model)
|
|
235
|
+
):
|
|
236
|
+
raise BackendError("invalid_config", "The native model selection is invalid.")
|
|
237
|
+
project = value.get("project_dir")
|
|
238
|
+
if action == "start" or project is not None:
|
|
239
|
+
if not isinstance(project, str) or len(project) > 4096 or not Path(project).is_absolute():
|
|
240
|
+
raise BackendError("path_not_allowed", "An absolute native project directory is required.")
|
|
241
|
+
# Retry identity uses the submitted spelling. A completed job may have moved the
|
|
242
|
+
# directory or retargeted a symlink; redelivery must still find that original input.
|
|
243
|
+
# The runtime canonicalizes and approves only genuinely new submissions below.
|
|
244
|
+
for name in ("files", "images"):
|
|
245
|
+
paths = value.get(name, ())
|
|
246
|
+
if (
|
|
247
|
+
not isinstance(paths, (list, tuple))
|
|
248
|
+
or len(paths) > 256
|
|
249
|
+
or any(not isinstance(path, str) or not path or len(path) > 4096 for path in paths)
|
|
250
|
+
):
|
|
251
|
+
raise BackendError("invalid_config", "Native context paths must be bounded lists of path strings.")
|
|
252
|
+
value[name] = tuple(paths)
|
|
253
|
+
return value
|
|
254
|
+
|
|
255
|
+
def _expire(self) -> None:
|
|
256
|
+
now = self._clock()
|
|
257
|
+
expired = {
|
|
258
|
+
handle
|
|
259
|
+
for handle, record in self._records.items()
|
|
260
|
+
if record.attempts[-1].state not in _ACTIVE
|
|
261
|
+
and record.last_terminal_at is not None
|
|
262
|
+
and now - record.last_terminal_at >= self.terminal_ttl
|
|
263
|
+
}
|
|
264
|
+
for handle in expired:
|
|
265
|
+
del self._records[handle]
|
|
266
|
+
if expired:
|
|
267
|
+
self._keys = {key: entry for key, entry in self._keys.items() if entry.task_handle not in expired}
|
|
268
|
+
self._session_owners = {
|
|
269
|
+
identity: handle for identity, handle in self._session_owners.items() if handle not in expired
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _fingerprint(args: dict[str, Any], action: str) -> str:
|
|
274
|
+
inputs = {key: value for key, value in args.items() if key != "request_key"}
|
|
275
|
+
return hashlib.sha256(
|
|
276
|
+
json.dumps({"action": action, **inputs}, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
277
|
+
).hexdigest()
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _record_size(record: _TaskRecord) -> int:
|
|
281
|
+
return _encoded_size(asdict(record))
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def retained_bytes(self) -> int:
|
|
285
|
+
"""Count retained context, index, attempt metadata, and worst-case active results."""
|
|
286
|
+
return (
|
|
287
|
+
sum(self._record_size(record) for record in self._records.values())
|
|
288
|
+
+ _encoded_size({key: asdict(value) for key, value in self._keys.items()})
|
|
289
|
+
+ _encoded_size(list(self._session_owners.items()))
|
|
290
|
+
+ _encoded_size({handle: str(project) for handle, project in self._leases.items()})
|
|
291
|
+
+ len(self._leases) * _COMPLETION_RESERVATION_BYTES
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _check_capacity(self, candidate: _TaskRecord, key: str, entry: _KeyEntry) -> None:
|
|
295
|
+
previous = self._records.get(candidate.task_handle)
|
|
296
|
+
extra = self._record_size(candidate) - (self._record_size(previous) if previous is not None else 0)
|
|
297
|
+
# Include index punctuation and reserve the largest permitted completion before launching.
|
|
298
|
+
extra += (
|
|
299
|
+
_encoded_size({key: asdict(entry)})
|
|
300
|
+
+ _encoded_size({candidate.task_handle: str(candidate.context.target.working_directory)})
|
|
301
|
+
+ _COMPLETION_RESERVATION_BYTES
|
|
302
|
+
)
|
|
303
|
+
if self.retained_bytes + extra > self.max_retained_bytes:
|
|
304
|
+
raise BackendError("task_capacity", "Native task retention capacity is full; no task was submitted.")
|
|
305
|
+
|
|
306
|
+
def _deduplicate(self, key: str, fingerprint: str) -> dict[str, Any] | None:
|
|
307
|
+
entry = self._keys.get(key)
|
|
308
|
+
if entry is None:
|
|
309
|
+
return None
|
|
310
|
+
if entry.fingerprint != fingerprint:
|
|
311
|
+
raise BackendError("request_key_conflict", "This request key was already used for different native input.")
|
|
312
|
+
record = self._records[entry.task_handle]
|
|
313
|
+
attempt = next(item for item in record.attempts if item.attempt_id == entry.attempt_id)
|
|
314
|
+
return self._snapshot(record, attempt, deduplicated=True)
|
|
315
|
+
|
|
316
|
+
def _reserve_project(self, handle: str, project: Path) -> None:
|
|
317
|
+
for active in self._leases.values():
|
|
318
|
+
if project == active or project in active.parents or active in project.parents:
|
|
319
|
+
raise BackendError("project_busy", "An overlapping native project already has active work.")
|
|
320
|
+
self._leases[handle] = project
|
|
321
|
+
|
|
322
|
+
def _admission_time(self, admitted_at: float | None) -> float:
|
|
323
|
+
value = self._clock() if admitted_at is None else admitted_at
|
|
324
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
325
|
+
raise BackendError("invalid_config", "Native admission time must be a finite monotonic timestamp.")
|
|
326
|
+
return value
|
|
327
|
+
|
|
328
|
+
def _prepare(self, *args: Any, **kwargs: Any) -> FrozenNativeContext:
|
|
329
|
+
try:
|
|
330
|
+
return self.runtime.prepare(*args, **kwargs)
|
|
331
|
+
except BackendError as exc:
|
|
332
|
+
code = exc.code if exc.code in _ERROR_CODES else "native_unavailable"
|
|
333
|
+
message, remedy = _PREPARE_DIAGNOSTICS.get(code, ("Native task admission could not be established.", ""))
|
|
334
|
+
selected_cli = args[0] if args else kwargs.get("cli_name")
|
|
335
|
+
if code == "path_not_allowed" and isinstance(selected_cli, str) and _NAME.fullmatch(selected_cli):
|
|
336
|
+
remedy = remedy.replace("--cli NAME", f"--cli {selected_cli}").replace(
|
|
337
|
+
"(replace NAME with the selected CLI; include", "(include"
|
|
338
|
+
)
|
|
339
|
+
missing = exc.metadata.get("missing_environment_names")
|
|
340
|
+
metadata = {}
|
|
341
|
+
if isinstance(missing, list):
|
|
342
|
+
metadata["missing_environment_names"] = [
|
|
343
|
+
name
|
|
344
|
+
for name in missing[:64]
|
|
345
|
+
if isinstance(name, str) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", name)
|
|
346
|
+
]
|
|
347
|
+
raise BackendError(code, message, remedy, metadata=metadata) from None
|
|
348
|
+
except Exception:
|
|
349
|
+
raise BackendError("native_unavailable", "Native task admission could not be established.") from None
|
|
350
|
+
|
|
351
|
+
async def start(
|
|
352
|
+
self, args: dict[str, Any], admitted_at: float | None = None, request_id: str | None = None
|
|
353
|
+
) -> dict[str, Any]:
|
|
354
|
+
"""Reserve the retry identity and project before scheduling server-owned execution."""
|
|
355
|
+
values = self._validate(args, "start")
|
|
356
|
+
fingerprint = self._fingerprint(values, "start")
|
|
357
|
+
submitted = self._admission_time(admitted_at)
|
|
358
|
+
async with self._lock:
|
|
359
|
+
self._expire()
|
|
360
|
+
duplicate = self._deduplicate(values["request_key"], fingerprint)
|
|
361
|
+
if duplicate is not None:
|
|
362
|
+
return duplicate
|
|
363
|
+
if self._closed:
|
|
364
|
+
raise BackendError("cancelled", "The native task manager is shutting down.")
|
|
365
|
+
if len(self._records) >= self.max_tasks:
|
|
366
|
+
raise BackendError("task_capacity", "Native task capacity is full; no task was submitted.")
|
|
367
|
+
context = self._prepare(
|
|
368
|
+
values["cli_name"],
|
|
369
|
+
values.get("role"),
|
|
370
|
+
values.get("model"),
|
|
371
|
+
values["project_dir"],
|
|
372
|
+
values["files"],
|
|
373
|
+
values["images"],
|
|
374
|
+
absolute_deadline=submitted + values["timeout_seconds"],
|
|
375
|
+
)
|
|
376
|
+
handle = "nt_" + uuid.uuid4().hex
|
|
377
|
+
attempt = _Attempt("na_" + uuid.uuid4().hex, "queued", submitted)
|
|
378
|
+
# Context attachments belong only to this input, never to future native follow-ups.
|
|
379
|
+
record = _TaskRecord(handle, replace(context, files=(), images=(), deadline=None), [attempt])
|
|
380
|
+
entry = _KeyEntry(fingerprint, handle, attempt.attempt_id)
|
|
381
|
+
self._check_capacity(record, values["request_key"], entry)
|
|
382
|
+
self._reserve_project(handle, context.target.working_directory)
|
|
383
|
+
self._records[handle] = record
|
|
384
|
+
self._keys[values["request_key"]] = entry
|
|
385
|
+
self._jobs[handle] = asyncio.create_task(
|
|
386
|
+
self._execute(handle, attempt, context, values["prompt"], None), name=f"native-{attempt.attempt_id}"
|
|
387
|
+
)
|
|
388
|
+
return self._snapshot(record, attempt)
|
|
389
|
+
|
|
390
|
+
async def followup(
|
|
391
|
+
self, args: dict[str, Any], admitted_at: float | None = None, request_id: str | None = None
|
|
392
|
+
) -> dict[str, Any]:
|
|
393
|
+
"""Submit only new input to the recorded native session and frozen selection."""
|
|
394
|
+
values = self._validate(args, "followup")
|
|
395
|
+
fingerprint = self._fingerprint(values, "followup")
|
|
396
|
+
submitted = self._admission_time(admitted_at)
|
|
397
|
+
async with self._lock:
|
|
398
|
+
self._expire()
|
|
399
|
+
duplicate = self._deduplicate(values["request_key"], fingerprint)
|
|
400
|
+
if duplicate is not None:
|
|
401
|
+
return duplicate
|
|
402
|
+
if self._closed:
|
|
403
|
+
raise BackendError("cancelled", "The native task manager is shutting down.")
|
|
404
|
+
record = self._records.get(values["task_handle"])
|
|
405
|
+
if record is None:
|
|
406
|
+
return self._unavailable(values["task_handle"])
|
|
407
|
+
if record.attempts[-1].state in _ACTIVE:
|
|
408
|
+
raise BackendError("task_busy", "This native task already has active input.")
|
|
409
|
+
if record.session_id is None:
|
|
410
|
+
raise BackendError(
|
|
411
|
+
"session_unavailable", "The recorded native session is unavailable; no task was submitted."
|
|
412
|
+
)
|
|
413
|
+
if len(record.attempts) >= self.max_attempts:
|
|
414
|
+
raise BackendError(
|
|
415
|
+
"task_capacity", "This native task reached its attempt limit; no input was submitted."
|
|
416
|
+
)
|
|
417
|
+
target = record.context.target
|
|
418
|
+
context = self._prepare(
|
|
419
|
+
values.get("cli_name") or target.cli_name,
|
|
420
|
+
values.get("role") or target.role.name,
|
|
421
|
+
values.get("model", target.requested_model),
|
|
422
|
+
values.get("project_dir") or str(target.working_directory),
|
|
423
|
+
values["files"],
|
|
424
|
+
values["images"],
|
|
425
|
+
absolute_deadline=submitted + values["timeout_seconds"],
|
|
426
|
+
)
|
|
427
|
+
identity = lambda item: ( # noqa: E731 -- keep the complete binding visible at the comparison.
|
|
428
|
+
item.target.cli_name,
|
|
429
|
+
item.target.role.name,
|
|
430
|
+
item.target.effective_model,
|
|
431
|
+
item.target.working_directory,
|
|
432
|
+
item.profile_id,
|
|
433
|
+
)
|
|
434
|
+
if identity(context) != identity(record.context):
|
|
435
|
+
raise BackendError("session_conflict", "Follow-up input conflicts with the recorded native selection.")
|
|
436
|
+
context = replace(record.context, files=context.files, images=context.images, deadline=context.deadline)
|
|
437
|
+
attempt = _Attempt("na_" + uuid.uuid4().hex, "queued", submitted)
|
|
438
|
+
candidate = replace(record, attempts=[*record.attempts, attempt], latest_result=None)
|
|
439
|
+
entry = _KeyEntry(fingerprint, record.task_handle, attempt.attempt_id)
|
|
440
|
+
self._check_capacity(candidate, values["request_key"], entry)
|
|
441
|
+
self._reserve_project(record.task_handle, target.working_directory)
|
|
442
|
+
self._records[record.task_handle] = candidate
|
|
443
|
+
self._keys[values["request_key"]] = entry
|
|
444
|
+
self._jobs[record.task_handle] = asyncio.create_task(
|
|
445
|
+
self._execute(record.task_handle, attempt, context, values["prompt"], record.session_id),
|
|
446
|
+
name=f"native-{attempt.attempt_id}",
|
|
447
|
+
)
|
|
448
|
+
return self._snapshot(candidate, attempt)
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def _public_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
|
452
|
+
safe: dict[str, Any] = {}
|
|
453
|
+
if not isinstance(metadata, dict):
|
|
454
|
+
return safe
|
|
455
|
+
for key in sorted(_METADATA_KEYS):
|
|
456
|
+
if key not in metadata:
|
|
457
|
+
continue
|
|
458
|
+
value = metadata[key]
|
|
459
|
+
# Scalars and short lists/maps only; arbitrary parser structures cannot bypass the result cap.
|
|
460
|
+
if isinstance(value, str):
|
|
461
|
+
value = value[:512]
|
|
462
|
+
elif value is None or isinstance(value, bool):
|
|
463
|
+
pass
|
|
464
|
+
elif isinstance(value, (int, float)):
|
|
465
|
+
try:
|
|
466
|
+
finite = math.isfinite(value) and abs(value) <= 10**15
|
|
467
|
+
except (OverflowError, TypeError):
|
|
468
|
+
finite = False
|
|
469
|
+
if not finite:
|
|
470
|
+
continue
|
|
471
|
+
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
472
|
+
value = [item[:128] for item in value[:32]]
|
|
473
|
+
else:
|
|
474
|
+
continue
|
|
475
|
+
try:
|
|
476
|
+
fits = _encoded_size({**safe, key: value}) <= RESULT_METADATA_BYTES
|
|
477
|
+
except (UnicodeEncodeError, ValueError):
|
|
478
|
+
fits = False
|
|
479
|
+
if fits:
|
|
480
|
+
safe[key] = value
|
|
481
|
+
return safe
|
|
482
|
+
|
|
483
|
+
async def _execute(
|
|
484
|
+
self,
|
|
485
|
+
handle: str,
|
|
486
|
+
attempt: _Attempt,
|
|
487
|
+
context: FrozenNativeContext,
|
|
488
|
+
prompt: str,
|
|
489
|
+
session_id: str | None,
|
|
490
|
+
) -> None:
|
|
491
|
+
outcome: NativeRunOutcome | None = None
|
|
492
|
+
state = "uncertain"
|
|
493
|
+
error_code = None
|
|
494
|
+
|
|
495
|
+
def started() -> None:
|
|
496
|
+
attempt.started_at = self._clock()
|
|
497
|
+
attempt.possible_partial_effects = True
|
|
498
|
+
attempt.state = "running"
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
outcome = await self.runtime.run(
|
|
502
|
+
context, prompt, session_id, attempt_id=attempt.attempt_id, started=started
|
|
503
|
+
)
|
|
504
|
+
if not isinstance(outcome, NativeRunOutcome):
|
|
505
|
+
outcome = None
|
|
506
|
+
raise BackendError("parse_error", "The native runtime returned no recognized terminal outcome.")
|
|
507
|
+
state = outcome.state if outcome.state in _TERMINAL else "uncertain"
|
|
508
|
+
native_error = outcome.metadata.get("error_code") if isinstance(outcome.metadata, dict) else None
|
|
509
|
+
error_code = native_error if isinstance(native_error, str) and native_error in _ERROR_CODES else None
|
|
510
|
+
attempt.possible_partial_effects = attempt.possible_partial_effects or outcome.partial_effects
|
|
511
|
+
except asyncio.CancelledError:
|
|
512
|
+
state = "cancelled"
|
|
513
|
+
except BackendError as exc:
|
|
514
|
+
error_code = exc.code if exc.code in _ERROR_CODES else "native_failed"
|
|
515
|
+
if error_code == "permission_needed":
|
|
516
|
+
state = "permission_needed"
|
|
517
|
+
else:
|
|
518
|
+
state = "uncertain" if attempt.started_at is not None else "failed"
|
|
519
|
+
except Exception:
|
|
520
|
+
error_code = "native_failed"
|
|
521
|
+
state = "uncertain" if attempt.started_at is not None else "failed"
|
|
522
|
+
finally:
|
|
523
|
+
# The runtime contract guarantees process cleanup is already complete at this point.
|
|
524
|
+
prompt = ""
|
|
525
|
+
async with self._lock:
|
|
526
|
+
record = self._records[handle]
|
|
527
|
+
if outcome is not None:
|
|
528
|
+
native_id = outcome.session_id
|
|
529
|
+
if native_id is not None and (
|
|
530
|
+
not isinstance(native_id, str)
|
|
531
|
+
or not native_id
|
|
532
|
+
or len(native_id) > 512
|
|
533
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in native_id)
|
|
534
|
+
):
|
|
535
|
+
native_id = None
|
|
536
|
+
state, error_code = "uncertain", "session_unavailable"
|
|
537
|
+
if native_id is not None:
|
|
538
|
+
owner = self._session_owners.get((record.context.target.cli_name, native_id))
|
|
539
|
+
if owner is not None and owner != handle:
|
|
540
|
+
state, error_code = "uncertain", "session_conflict"
|
|
541
|
+
elif record.session_id is not None and record.session_id != native_id:
|
|
542
|
+
state, error_code = "uncertain", "session_conflict"
|
|
543
|
+
else:
|
|
544
|
+
record.session_id = native_id
|
|
545
|
+
self._session_owners[(record.context.target.cli_name, native_id)] = handle
|
|
546
|
+
raw_content = outcome.content if isinstance(outcome.content, str) else ""
|
|
547
|
+
# Invalid Unicode is treated as untrusted output rather than failing finalization.
|
|
548
|
+
content = raw_content[:RESULT_CHARACTERS].encode("utf-8", errors="replace").decode("utf-8")
|
|
549
|
+
record.latest_result = {
|
|
550
|
+
"content": content,
|
|
551
|
+
"truncated": len(raw_content) > RESULT_CHARACTERS
|
|
552
|
+
or (isinstance(outcome.metadata, dict) and outcome.metadata.get("truncated") is True),
|
|
553
|
+
"agent_reported_claims": "unverified",
|
|
554
|
+
"metadata": self._public_metadata(outcome.metadata),
|
|
555
|
+
}
|
|
556
|
+
attempt.state = state
|
|
557
|
+
attempt.error_code = error_code
|
|
558
|
+
attempt.terminal_at = self._clock()
|
|
559
|
+
record.last_terminal_at = attempt.terminal_at
|
|
560
|
+
self._leases.pop(handle, None)
|
|
561
|
+
self._jobs.pop(handle, None)
|
|
562
|
+
logger.info(
|
|
563
|
+
"native_attempt task_handle=%s attempt_id=%s state=%s possible_partial_effects=%s",
|
|
564
|
+
handle,
|
|
565
|
+
attempt.attempt_id,
|
|
566
|
+
state,
|
|
567
|
+
attempt.possible_partial_effects,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
@staticmethod
|
|
571
|
+
def _unavailable(handle: str) -> dict[str, Any]:
|
|
572
|
+
return {
|
|
573
|
+
"task_handle": handle,
|
|
574
|
+
"state": "unavailable",
|
|
575
|
+
"execution_outcome": "unknown",
|
|
576
|
+
"result_unavailable": True,
|
|
577
|
+
"execution_does_not_verify_task_fulfillment": True,
|
|
578
|
+
"possible_partial_effects": True,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
@staticmethod
|
|
582
|
+
def _snapshot(record: _TaskRecord, attempt: _Attempt, *, deduplicated: bool = False) -> dict[str, Any]:
|
|
583
|
+
latest = record.attempts[-1] is attempt
|
|
584
|
+
result = record.latest_result if latest else None
|
|
585
|
+
response: dict[str, Any] = {
|
|
586
|
+
"task_handle": record.task_handle,
|
|
587
|
+
"attempt_id": attempt.attempt_id,
|
|
588
|
+
"state": attempt.state,
|
|
589
|
+
"execution_outcome": "unknown" if attempt.state == "uncertain" else attempt.state,
|
|
590
|
+
"admitted_at": attempt.admitted_at,
|
|
591
|
+
"started_at": attempt.started_at,
|
|
592
|
+
"terminal_at": attempt.terminal_at,
|
|
593
|
+
"possible_partial_effects": attempt.possible_partial_effects,
|
|
594
|
+
"cancellation_requested": attempt.cancel_requested,
|
|
595
|
+
"execution_does_not_verify_task_fulfillment": True,
|
|
596
|
+
"result_unavailable": result is None,
|
|
597
|
+
"deduplicated": deduplicated,
|
|
598
|
+
}
|
|
599
|
+
if result is not None:
|
|
600
|
+
# Return detached data so callers cannot mutate retained result metadata.
|
|
601
|
+
response["result"] = json.loads(json.dumps(result))
|
|
602
|
+
if attempt.error_code is not None:
|
|
603
|
+
response["error"] = {"code": attempt.error_code, "message": "Native execution did not establish success."}
|
|
604
|
+
if latest:
|
|
605
|
+
response["followup_available"] = record.session_id is not None and attempt.state not in _ACTIVE
|
|
606
|
+
return response
|
|
607
|
+
|
|
608
|
+
async def status(self, args: dict[str, Any] | str) -> dict[str, Any]:
|
|
609
|
+
values = self._validate({"task_handle": args} if isinstance(args, str) else args, "status")
|
|
610
|
+
async with self._lock:
|
|
611
|
+
self._expire()
|
|
612
|
+
record = self._records.get(values["task_handle"])
|
|
613
|
+
if record is None:
|
|
614
|
+
return self._unavailable(values["task_handle"])
|
|
615
|
+
return self._snapshot(record, record.attempts[-1])
|
|
616
|
+
|
|
617
|
+
async def _cancel_job(self, handle: str, attempt: _Attempt, job: asyncio.Task[None]) -> None:
|
|
618
|
+
await asyncio.gather(job, return_exceptions=True)
|
|
619
|
+
async with self._lock:
|
|
620
|
+
# A task cancelled before its coroutine first executes has no finally block.
|
|
621
|
+
if attempt.state in _ACTIVE:
|
|
622
|
+
attempt.state = "cancelled"
|
|
623
|
+
attempt.terminal_at = self._clock()
|
|
624
|
+
self._records[handle].last_terminal_at = attempt.terminal_at
|
|
625
|
+
self._leases.pop(handle, None)
|
|
626
|
+
self._jobs.pop(handle, None)
|
|
627
|
+
|
|
628
|
+
@staticmethod
|
|
629
|
+
async def _finish_cleanup(cleanup: asyncio.Task[Any]) -> None:
|
|
630
|
+
interrupted = False
|
|
631
|
+
while not cleanup.done():
|
|
632
|
+
try:
|
|
633
|
+
await asyncio.shield(cleanup)
|
|
634
|
+
except asyncio.CancelledError:
|
|
635
|
+
interrupted = True
|
|
636
|
+
cleanup.result()
|
|
637
|
+
if interrupted:
|
|
638
|
+
raise asyncio.CancelledError
|
|
639
|
+
|
|
640
|
+
async def cancel(self, args: dict[str, Any] | str) -> dict[str, Any]:
|
|
641
|
+
values = self._validate({"task_handle": args} if isinstance(args, str) else args, "cancel")
|
|
642
|
+
async with self._lock:
|
|
643
|
+
self._expire()
|
|
644
|
+
record = self._records.get(values["task_handle"])
|
|
645
|
+
if record is None:
|
|
646
|
+
return self._unavailable(values["task_handle"])
|
|
647
|
+
attempt = record.attempts[-1]
|
|
648
|
+
job = self._jobs.get(record.task_handle)
|
|
649
|
+
if job is None or attempt.state not in _ACTIVE:
|
|
650
|
+
return self._snapshot(record, attempt)
|
|
651
|
+
if not attempt.cancel_requested:
|
|
652
|
+
attempt.cancel_requested = True
|
|
653
|
+
job.cancel()
|
|
654
|
+
cleanup = asyncio.create_task(self._cancel_job(record.task_handle, attempt, job))
|
|
655
|
+
await self._finish_cleanup(cleanup)
|
|
656
|
+
async with self._lock:
|
|
657
|
+
# Another explicit follow-up may already own the task. Return the cancelled input only.
|
|
658
|
+
return self._snapshot(self._records.get(record.task_handle, record), attempt)
|
|
659
|
+
|
|
660
|
+
async def shutdown(self) -> None:
|
|
661
|
+
"""Close admission and await every process cleanup before releasing native ownership."""
|
|
662
|
+
async with self._lock:
|
|
663
|
+
self._closed = True
|
|
664
|
+
pending = []
|
|
665
|
+
for handle, job in tuple(self._jobs.items()):
|
|
666
|
+
attempt = self._records[handle].attempts[-1]
|
|
667
|
+
if not attempt.cancel_requested:
|
|
668
|
+
attempt.cancel_requested = True
|
|
669
|
+
job.cancel()
|
|
670
|
+
pending.append(asyncio.create_task(self._cancel_job(handle, attempt, job)))
|
|
671
|
+
if pending:
|
|
672
|
+
|
|
673
|
+
async def finish() -> None:
|
|
674
|
+
await asyncio.gather(*pending)
|
|
675
|
+
|
|
676
|
+
await self._finish_cleanup(asyncio.create_task(finish()))
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
_TASK_MANAGER: NativeTaskManager | None = None
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def get_task_manager() -> NativeTaskManager:
|
|
683
|
+
"""Lazily share the server-owned manager without creating another process pool."""
|
|
684
|
+
global _TASK_MANAGER
|
|
685
|
+
if _TASK_MANAGER is None:
|
|
686
|
+
from clink.native import get_native_runtime
|
|
687
|
+
|
|
688
|
+
_TASK_MANAGER = NativeTaskManager(get_native_runtime())
|
|
689
|
+
return _TASK_MANAGER
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
async def shutdown_task_manager() -> None:
|
|
693
|
+
"""Drain only an existing native manager; shutdown must never instantiate a backend."""
|
|
694
|
+
if _TASK_MANAGER is not None:
|
|
695
|
+
await _TASK_MANAGER.shutdown()
|