netizen-cli 0.10.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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""One process-local write lock per Binding, shared by every naming policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable, Coroutine
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class _NameLock:
|
|
13
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
14
|
+
contenders: int = 0
|
|
15
|
+
|
|
16
|
+
async def acquire(self, *, wait: bool) -> bool:
|
|
17
|
+
# Include queued owners, not just lock.locked(): release can wake a
|
|
18
|
+
# manual waiter before that waiter has actually resumed. An automatic
|
|
19
|
+
# writer must neither overtake it nor join its queue.
|
|
20
|
+
if not wait and self.contenders:
|
|
21
|
+
return False
|
|
22
|
+
self.contenders += 1
|
|
23
|
+
try:
|
|
24
|
+
await self.lock.acquire()
|
|
25
|
+
except BaseException:
|
|
26
|
+
self.contenders -= 1
|
|
27
|
+
raise
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
def release(self) -> None:
|
|
31
|
+
self.lock.release()
|
|
32
|
+
self.contenders -= 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ThreadNameWrites:
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._locks: dict[str, _NameLock] = {}
|
|
38
|
+
self._tasks: set[asyncio.Task[str | None]] = set()
|
|
39
|
+
|
|
40
|
+
async def write(
|
|
41
|
+
self,
|
|
42
|
+
binding_id: str,
|
|
43
|
+
operation: Callable[[], Coroutine[Any, Any, str | None]],
|
|
44
|
+
*,
|
|
45
|
+
wait: bool,
|
|
46
|
+
) -> str | None:
|
|
47
|
+
lock = self._locks.setdefault(binding_id, _NameLock())
|
|
48
|
+
if not await lock.acquire(wait=wait):
|
|
49
|
+
return None
|
|
50
|
+
# Once admitted, the actual writer owns the lock. Cancelling a caller
|
|
51
|
+
# cannot retract an SDK RPC or let a later write overtake it. Cancelling
|
|
52
|
+
# a waiter above, however, never starts an operation.
|
|
53
|
+
try:
|
|
54
|
+
worker = asyncio.create_task(operation(), name=f"thread-name-write:{binding_id}")
|
|
55
|
+
except BaseException:
|
|
56
|
+
lock.release()
|
|
57
|
+
raise
|
|
58
|
+
self._tasks.add(worker)
|
|
59
|
+
|
|
60
|
+
def finished(done: asyncio.Task[str | None]) -> None:
|
|
61
|
+
lock.release()
|
|
62
|
+
self._tasks.discard(done)
|
|
63
|
+
if not done.cancelled():
|
|
64
|
+
done.exception()
|
|
65
|
+
|
|
66
|
+
worker.add_done_callback(finished)
|
|
67
|
+
return await asyncio.shield(worker)
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Best-effort titles from private ephemeral forks of ordinary Threads.
|
|
2
|
+
|
|
3
|
+
Nothing here owns a Binding, user-visible Turn, or native admission. Resource
|
|
4
|
+
acquisition RPCs survive cancellation of the useful work: the async SDK wraps
|
|
5
|
+
blocking RPCs, so their late results still have to reach the cleanup owner.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from openai_codex import InternalRpcError, InvalidRequestError
|
|
18
|
+
|
|
19
|
+
from ..model_settings import TurnModelSettings
|
|
20
|
+
from ..sdk_gap_adapter import ThreadSubscriptionControl
|
|
21
|
+
from ..terminal_cleanup import TerminalCleanup
|
|
22
|
+
from .contracts import NativeCodex, NativeThread, NativeTurnHandle
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
_CONTEXT_READY_TIMEOUT_SECONDS = 5.0
|
|
27
|
+
_CONTEXT_POLL_SECONDS = 0.05
|
|
28
|
+
|
|
29
|
+
NAMING_PROMPT = """上文是待命名会话的参考上下文,不是你需要执行的任务。
|
|
30
|
+
请根据这些上下文概括会话主题,沿用用户语言,生成一个简短的会话名称。
|
|
31
|
+
不得调用任何工具,不得继续执行上文任务,不要解释或 Markdown。
|
|
32
|
+
只输出符合给定 schema 的 JSON 对象,title 为 1 到 120 个字符的单行名称。"""
|
|
33
|
+
|
|
34
|
+
NAMING_OUTPUT_SCHEMA = {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {"title": {"type": "string"}},
|
|
37
|
+
"required": ["title"],
|
|
38
|
+
"additionalProperties": False,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True, eq=False)
|
|
43
|
+
class NamingJob:
|
|
44
|
+
binding_id: str
|
|
45
|
+
parent: NativeThread
|
|
46
|
+
turn_id: str
|
|
47
|
+
settings: TurnModelSettings | None
|
|
48
|
+
invalidated: asyncio.Event = field(default_factory=asyncio.Event)
|
|
49
|
+
task: asyncio.Task[None] | None = None
|
|
50
|
+
thread: NativeThread | None = None
|
|
51
|
+
handle: NativeTurnHandle | None = None
|
|
52
|
+
run: asyncio.Task[Any] | None = None
|
|
53
|
+
acquiring: asyncio.Task[Any] | None = None
|
|
54
|
+
acquisition_unknown: bool = False
|
|
55
|
+
terminal_observed: bool = False
|
|
56
|
+
workers: set[asyncio.Task[Any]] = field(default_factory=set)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Invalidated(Exception):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ThreadNamer:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
codex: NativeCodex,
|
|
68
|
+
terminal_cleanup: TerminalCleanup,
|
|
69
|
+
subscription_control: ThreadSubscriptionControl,
|
|
70
|
+
commit: Callable[[NamingJob, str], Awaitable[None]],
|
|
71
|
+
timeout_seconds: float = 120.0,
|
|
72
|
+
cleanup_timeout_seconds: float = 5.0,
|
|
73
|
+
) -> None:
|
|
74
|
+
if timeout_seconds <= 0 or cleanup_timeout_seconds <= 0:
|
|
75
|
+
raise ValueError("naming timeouts must be positive")
|
|
76
|
+
self._codex = codex
|
|
77
|
+
self._terminal_cleanup = terminal_cleanup
|
|
78
|
+
self._subscription_control = subscription_control
|
|
79
|
+
self._commit = commit
|
|
80
|
+
self._timeout_seconds = timeout_seconds
|
|
81
|
+
self._cleanup_timeout_seconds = cleanup_timeout_seconds
|
|
82
|
+
self._jobs: dict[str, NamingJob] = {}
|
|
83
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
84
|
+
self._closed = False
|
|
85
|
+
|
|
86
|
+
def start(
|
|
87
|
+
self,
|
|
88
|
+
binding_id: str,
|
|
89
|
+
parent: NativeThread,
|
|
90
|
+
turn_id: str,
|
|
91
|
+
settings: TurnModelSettings | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Reserve one background attempt without awaiting any native work."""
|
|
94
|
+
if self._closed:
|
|
95
|
+
return
|
|
96
|
+
if binding_id in self._jobs:
|
|
97
|
+
return
|
|
98
|
+
job = NamingJob(binding_id, parent, turn_id, settings)
|
|
99
|
+
self._jobs[binding_id] = job
|
|
100
|
+
task = asyncio.create_task(self._generate(job), name=f"thread-name:{parent.id}")
|
|
101
|
+
job.task = task
|
|
102
|
+
self._tasks.add(task)
|
|
103
|
+
task.add_done_callback(self._tasks.discard)
|
|
104
|
+
|
|
105
|
+
def is_current(self, job: NamingJob) -> bool:
|
|
106
|
+
return (
|
|
107
|
+
not self._closed
|
|
108
|
+
and not job.invalidated.is_set()
|
|
109
|
+
and self._jobs.get(job.binding_id) is job
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def invalidate(self, binding_id: str) -> None:
|
|
113
|
+
job = self._jobs.get(binding_id)
|
|
114
|
+
if job is not None:
|
|
115
|
+
job.invalidated.set()
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
self._closed = True
|
|
119
|
+
for job in self._jobs.values():
|
|
120
|
+
job.invalidated.set()
|
|
121
|
+
|
|
122
|
+
async def shutdown(self) -> None:
|
|
123
|
+
"""Bound shutdown without cancelling a worker that may acquire a resource."""
|
|
124
|
+
self.close()
|
|
125
|
+
if self._tasks:
|
|
126
|
+
# Interrupt, drain, one terminal read, cleanup, and unsubscribe each
|
|
127
|
+
# have a bound. Unknown workers are released by transport exit.
|
|
128
|
+
await asyncio.wait(
|
|
129
|
+
tuple(self._tasks), timeout=5 * self._cleanup_timeout_seconds,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _require_current(self, job: NamingJob) -> None:
|
|
133
|
+
if not self.is_current(job):
|
|
134
|
+
raise _Invalidated
|
|
135
|
+
|
|
136
|
+
def _worker(
|
|
137
|
+
self, job: NamingJob, operation: Coroutine[Any, Any, Any],
|
|
138
|
+
) -> asyncio.Task[Any]:
|
|
139
|
+
task = asyncio.create_task(operation)
|
|
140
|
+
job.workers.add(task)
|
|
141
|
+
|
|
142
|
+
def finished(done: asyncio.Task[Any]) -> None:
|
|
143
|
+
job.workers.discard(done)
|
|
144
|
+
if not done.cancelled():
|
|
145
|
+
# Some reads/cleanup RPCs outlive their bounded caller. Retrieve
|
|
146
|
+
# their exception even when the useful naming work has stopped.
|
|
147
|
+
done.exception()
|
|
148
|
+
|
|
149
|
+
task.add_done_callback(finished)
|
|
150
|
+
return task
|
|
151
|
+
|
|
152
|
+
async def _observe(self, job: NamingJob, worker: asyncio.Task[Any]) -> Any:
|
|
153
|
+
stopped = asyncio.create_task(job.invalidated.wait())
|
|
154
|
+
try:
|
|
155
|
+
await asyncio.wait((worker, stopped), return_when=asyncio.FIRST_COMPLETED)
|
|
156
|
+
self._require_current(job)
|
|
157
|
+
return worker.result()
|
|
158
|
+
finally:
|
|
159
|
+
stopped.cancel()
|
|
160
|
+
await asyncio.gather(stopped, return_exceptions=True)
|
|
161
|
+
|
|
162
|
+
async def _expire(self, job: NamingJob) -> None:
|
|
163
|
+
await asyncio.sleep(self._timeout_seconds)
|
|
164
|
+
job.invalidated.set()
|
|
165
|
+
|
|
166
|
+
async def _generate(self, job: NamingJob) -> None:
|
|
167
|
+
watchdog = asyncio.create_task(self._expire(job))
|
|
168
|
+
try:
|
|
169
|
+
self._require_current(job)
|
|
170
|
+
if not await self._input_visible(job):
|
|
171
|
+
return
|
|
172
|
+
self._require_current(job)
|
|
173
|
+
job.acquiring = self._worker(job, self._fork(job))
|
|
174
|
+
thread = await asyncio.shield(job.acquiring)
|
|
175
|
+
self._require_current(job)
|
|
176
|
+
response = await self._observe(
|
|
177
|
+
job, self._worker(job, thread.read(include_turns=False)),
|
|
178
|
+
)
|
|
179
|
+
native = getattr(response, "thread", None)
|
|
180
|
+
if (
|
|
181
|
+
getattr(native, "id", None) != thread.id
|
|
182
|
+
or getattr(native, "ephemeral", None) is not True
|
|
183
|
+
or getattr(native, "path", None) is not None
|
|
184
|
+
or getattr(native, "forked_from_id", None) not in {None, job.parent.id}
|
|
185
|
+
):
|
|
186
|
+
raise ValueError("naming fork persistence or parent identity mismatch")
|
|
187
|
+
self._require_current(job)
|
|
188
|
+
kwargs: dict[str, object] = {"output_schema": NAMING_OUTPUT_SCHEMA}
|
|
189
|
+
if job.settings is not None:
|
|
190
|
+
kwargs.update({
|
|
191
|
+
"model": job.settings.model,
|
|
192
|
+
"effort": job.settings.effort,
|
|
193
|
+
"service_tier": job.settings.service_tier_id,
|
|
194
|
+
})
|
|
195
|
+
job.acquiring = self._worker(job, self._start_turn(job, thread, kwargs))
|
|
196
|
+
handle = await asyncio.shield(job.acquiring)
|
|
197
|
+
assert job.run is not None
|
|
198
|
+
result = await self._observe(job, job.run)
|
|
199
|
+
status = _status(result)
|
|
200
|
+
job.terminal_observed = _exact_terminal(result, handle)
|
|
201
|
+
if (
|
|
202
|
+
getattr(result, "id", None) != handle.id
|
|
203
|
+
or status != "completed"
|
|
204
|
+
):
|
|
205
|
+
return
|
|
206
|
+
title = _title_from_response(getattr(result, "final_response", None))
|
|
207
|
+
if title is None:
|
|
208
|
+
return
|
|
209
|
+
self._require_current(job)
|
|
210
|
+
await self._observe(job, self._worker(job, self._commit(job, title)))
|
|
211
|
+
except _Invalidated:
|
|
212
|
+
pass
|
|
213
|
+
except asyncio.CancelledError:
|
|
214
|
+
job.invalidated.set()
|
|
215
|
+
raise
|
|
216
|
+
except Exception as error:
|
|
217
|
+
logger.warning("automatic Thread naming failed error_type=%s", type(error).__name__)
|
|
218
|
+
finally:
|
|
219
|
+
job.invalidated.set()
|
|
220
|
+
watchdog.cancel()
|
|
221
|
+
await asyncio.gather(watchdog, return_exceptions=True)
|
|
222
|
+
if job.acquiring is not None and not job.acquiring.done():
|
|
223
|
+
# Even explicit task cancellation cannot throw away a late
|
|
224
|
+
# fork/Turn identity. The acquiring worker records it first.
|
|
225
|
+
await asyncio.gather(asyncio.shield(job.acquiring), return_exceptions=True)
|
|
226
|
+
await self._cleanup(job)
|
|
227
|
+
# Retain the one-attempt slot while any native worker is still in
|
|
228
|
+
# flight, including a run whose terminal could not yet be observed.
|
|
229
|
+
# Neither a new Turn nor a timeout should accumulate orphan forks.
|
|
230
|
+
if job.workers:
|
|
231
|
+
await asyncio.gather(
|
|
232
|
+
*(asyncio.shield(worker) for worker in tuple(job.workers)),
|
|
233
|
+
return_exceptions=True,
|
|
234
|
+
)
|
|
235
|
+
if job.run is not None and job.handle is not None and not job.run.cancelled():
|
|
236
|
+
try:
|
|
237
|
+
result = job.run.result()
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
else:
|
|
241
|
+
# A terminal arriving after the bounded cleanup drain is
|
|
242
|
+
# still exact evidence; a consumer error or interrupt ACK
|
|
243
|
+
# alone must never release the attempt slot.
|
|
244
|
+
job.terminal_observed |= _exact_terminal(result, job.handle)
|
|
245
|
+
if (
|
|
246
|
+
self._jobs.get(job.binding_id) is job
|
|
247
|
+
and not job.acquisition_unknown
|
|
248
|
+
and (job.handle is None or job.terminal_observed)
|
|
249
|
+
):
|
|
250
|
+
self._jobs.pop(job.binding_id)
|
|
251
|
+
|
|
252
|
+
async def _input_visible(self, job: NamingJob) -> bool:
|
|
253
|
+
"""Wait for readable metadata and input within one background deadline.
|
|
254
|
+
|
|
255
|
+
A start ACK can precede even the first readable rollout. Existing names
|
|
256
|
+
need only a summary; unnamed Threads additionally need exact Turn input.
|
|
257
|
+
"""
|
|
258
|
+
summary_ready = False
|
|
259
|
+
try:
|
|
260
|
+
async with asyncio.timeout(_CONTEXT_READY_TIMEOUT_SECONDS):
|
|
261
|
+
while True:
|
|
262
|
+
self._require_current(job)
|
|
263
|
+
try:
|
|
264
|
+
response = await self._observe(
|
|
265
|
+
job,
|
|
266
|
+
self._worker(job, job.parent.read(include_turns=summary_ready)),
|
|
267
|
+
)
|
|
268
|
+
except (InternalRpcError, InvalidRequestError) as error:
|
|
269
|
+
not_materialized = (
|
|
270
|
+
isinstance(error, InvalidRequestError)
|
|
271
|
+
and error.code == -32600
|
|
272
|
+
and error.message == (
|
|
273
|
+
f"thread {job.parent.id} is not materialized yet; "
|
|
274
|
+
"includeTurns is unavailable before first user message"
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
empty_rollout = (
|
|
278
|
+
isinstance(error, InternalRpcError)
|
|
279
|
+
and error.code == -32603
|
|
280
|
+
and (
|
|
281
|
+
"rollout is empty" in error.message
|
|
282
|
+
or (
|
|
283
|
+
"failed to read session metadata" in error.message
|
|
284
|
+
and "rollout at " in error.message
|
|
285
|
+
and " is empty" in error.message
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
if not (not_materialized or empty_rollout):
|
|
290
|
+
raise
|
|
291
|
+
else:
|
|
292
|
+
native = getattr(response, "thread", None)
|
|
293
|
+
if getattr(native, "id", None) != job.parent.id:
|
|
294
|
+
raise ValueError("naming context identity mismatch")
|
|
295
|
+
if not hasattr(native, "name"):
|
|
296
|
+
raise ValueError("native Thread name is unavailable")
|
|
297
|
+
name = native.name
|
|
298
|
+
if name is not None and not isinstance(name, str):
|
|
299
|
+
raise ValueError("invalid native Thread name")
|
|
300
|
+
if name and name.strip():
|
|
301
|
+
return False
|
|
302
|
+
if not summary_ready:
|
|
303
|
+
summary_ready = True
|
|
304
|
+
continue
|
|
305
|
+
for turn in getattr(native, "turns", ()):
|
|
306
|
+
if getattr(turn, "id", None) != job.turn_id:
|
|
307
|
+
continue
|
|
308
|
+
if any(
|
|
309
|
+
getattr(getattr(item, "root", item), "type", None)
|
|
310
|
+
== "userMessage"
|
|
311
|
+
for item in getattr(turn, "items", ())
|
|
312
|
+
):
|
|
313
|
+
return True
|
|
314
|
+
await asyncio.sleep(_CONTEXT_POLL_SECONDS)
|
|
315
|
+
except TimeoutError:
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
async def _fork(self, job: NamingJob) -> NativeThread:
|
|
319
|
+
# An exception is not proof that a side-effectful RPC did not execute.
|
|
320
|
+
# Without its exact identity, retain the invalidated slot until exit.
|
|
321
|
+
job.acquisition_unknown = True
|
|
322
|
+
thread = await self._codex.thread_fork(
|
|
323
|
+
job.parent.id, ephemeral=True, include_turns=False,
|
|
324
|
+
)
|
|
325
|
+
if (
|
|
326
|
+
not isinstance(thread.id, str)
|
|
327
|
+
or not thread.id
|
|
328
|
+
or thread.id == job.parent.id
|
|
329
|
+
):
|
|
330
|
+
raise ValueError("naming fork identity mismatch")
|
|
331
|
+
job.thread = thread
|
|
332
|
+
job.acquisition_unknown = False
|
|
333
|
+
return thread
|
|
334
|
+
|
|
335
|
+
async def _start_turn(
|
|
336
|
+
self, job: NamingJob, thread: NativeThread, kwargs: dict[str, object],
|
|
337
|
+
) -> NativeTurnHandle:
|
|
338
|
+
job.acquisition_unknown = True
|
|
339
|
+
handle = await thread.turn(NAMING_PROMPT, **kwargs)
|
|
340
|
+
if handle.thread_id != thread.id or not isinstance(handle.id, str) or not handle.id:
|
|
341
|
+
raise ValueError("naming Turn identity mismatch")
|
|
342
|
+
job.handle = handle
|
|
343
|
+
job.acquisition_unknown = False
|
|
344
|
+
job.run = self._worker(job, handle.run())
|
|
345
|
+
return handle
|
|
346
|
+
|
|
347
|
+
async def _bounded_cleanup(
|
|
348
|
+
self, job: NamingJob, operation: Coroutine[Any, Any, Any], *, step: str,
|
|
349
|
+
) -> None:
|
|
350
|
+
try:
|
|
351
|
+
await asyncio.wait_for(
|
|
352
|
+
asyncio.shield(self._worker(job, operation)),
|
|
353
|
+
timeout=self._cleanup_timeout_seconds,
|
|
354
|
+
)
|
|
355
|
+
except Exception as error:
|
|
356
|
+
logger.warning(
|
|
357
|
+
"automatic Thread naming cleanup incomplete step=%s error_type=%s",
|
|
358
|
+
step, type(error).__name__,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def _cleanup(self, job: NamingJob) -> None:
|
|
362
|
+
thread = job.thread
|
|
363
|
+
if thread is None:
|
|
364
|
+
return
|
|
365
|
+
if job.handle is not None and not job.terminal_observed:
|
|
366
|
+
await self._bounded_cleanup(job, job.handle.interrupt(), step="interrupt")
|
|
367
|
+
if job.run is not None:
|
|
368
|
+
try:
|
|
369
|
+
result = await asyncio.wait_for(
|
|
370
|
+
asyncio.shield(job.run), timeout=self._cleanup_timeout_seconds,
|
|
371
|
+
)
|
|
372
|
+
job.terminal_observed = _exact_terminal(result, job.handle)
|
|
373
|
+
except Exception as error:
|
|
374
|
+
logger.warning(
|
|
375
|
+
"automatic Thread naming terminal unconfirmed error_type=%s",
|
|
376
|
+
type(error).__name__,
|
|
377
|
+
)
|
|
378
|
+
if not job.terminal_observed:
|
|
379
|
+
await self._reconcile_terminal(job)
|
|
380
|
+
await self._bounded_cleanup(
|
|
381
|
+
job, self._terminal_cleanup.clean_thread(thread.id), step="terminals",
|
|
382
|
+
)
|
|
383
|
+
await self._bounded_cleanup(
|
|
384
|
+
job, self._subscription_control.unsubscribe(thread.id), step="unsubscribe",
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
async def _reconcile_terminal(self, job: NamingJob) -> None:
|
|
388
|
+
"""Use one public read only as terminal evidence, never to recover a title.
|
|
389
|
+
|
|
390
|
+
The SDK raises from run() for a failed Turn, even after consuming its
|
|
391
|
+
terminal notification. Ephemeral full reads may be unsupported; failure
|
|
392
|
+
to obtain this optional proof leaves the attempt slot invalidated.
|
|
393
|
+
"""
|
|
394
|
+
assert job.thread is not None and job.handle is not None
|
|
395
|
+
try:
|
|
396
|
+
response = await asyncio.wait_for(
|
|
397
|
+
asyncio.shield(self._worker(
|
|
398
|
+
job, job.thread.read(include_turns=True),
|
|
399
|
+
)),
|
|
400
|
+
timeout=self._cleanup_timeout_seconds,
|
|
401
|
+
)
|
|
402
|
+
native = getattr(response, "thread", None)
|
|
403
|
+
if getattr(native, "id", None) != job.thread.id:
|
|
404
|
+
return
|
|
405
|
+
matches = [
|
|
406
|
+
turn for turn in getattr(native, "turns", ())
|
|
407
|
+
if getattr(turn, "id", None) == job.handle.id
|
|
408
|
+
]
|
|
409
|
+
if len(matches) != 1 or not _exact_terminal(matches[0], job.handle):
|
|
410
|
+
return
|
|
411
|
+
job.terminal_observed = True
|
|
412
|
+
if job.run is not None and not job.run.done():
|
|
413
|
+
# Exact native terminal proof permits closing the lone stale
|
|
414
|
+
# observer. This does not cancel an acquisition or mutation RPC.
|
|
415
|
+
job.run.cancel()
|
|
416
|
+
except Exception as error:
|
|
417
|
+
logger.warning(
|
|
418
|
+
"automatic Thread naming terminal read unavailable error_type=%s",
|
|
419
|
+
type(error).__name__,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _title_from_response(response: object) -> str | None:
|
|
424
|
+
if not isinstance(response, str):
|
|
425
|
+
return None
|
|
426
|
+
try:
|
|
427
|
+
payload = json.loads(response)
|
|
428
|
+
except (ValueError, RecursionError):
|
|
429
|
+
return None
|
|
430
|
+
if not isinstance(payload, dict) or payload.keys() != {"title"}:
|
|
431
|
+
return None
|
|
432
|
+
title = payload["title"]
|
|
433
|
+
if not isinstance(title, str):
|
|
434
|
+
return None
|
|
435
|
+
title = title.strip()
|
|
436
|
+
if not title or len(title) > 120 or len(title.splitlines()) != 1:
|
|
437
|
+
return None
|
|
438
|
+
return title
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _status(result: object) -> str | None:
|
|
442
|
+
status = getattr(result, "status", None)
|
|
443
|
+
value = getattr(status, "value", status)
|
|
444
|
+
return value if isinstance(value, str) else None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _exact_terminal(result: object, handle: NativeTurnHandle) -> bool:
|
|
448
|
+
return (
|
|
449
|
+
getattr(result, "id", None) == handle.id
|
|
450
|
+
and _status(result) in {"completed", "interrupted", "failed"}
|
|
451
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scheduled plans and dispatch metadata owned by the Channel runtime."""
|