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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. 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."""