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,374 @@
|
|
|
1
|
+
"""Claim due plans and hand them to ordinary Channel/Runtime execution.
|
|
2
|
+
|
|
3
|
+
Only dispatch creation is owned here. Native Turns keep their ordinary single
|
|
4
|
+
consumer; this timer never waits for them or resumes them after a restart.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from ..bindings import BindingStore, ProjectDeleting
|
|
17
|
+
from .models import Claim, MutationResult, Run, ScheduleError, ScheduleNotFound
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
TICK_INTERVAL_SECONDS = 60.0
|
|
22
|
+
DUE_BATCH_SIZE = 100
|
|
23
|
+
RECOVERY_TIMEOUT_SECONDS = 10.0
|
|
24
|
+
READ_TIMEOUT_SECONDS = 3.0
|
|
25
|
+
DISPATCH_TIMEOUT_SECONDS = 30.0
|
|
26
|
+
BINDING_DISPATCH_TIMEOUT_SECONDS = 180.0
|
|
27
|
+
_TERMINAL = {"completed", "interrupted", "failed"}
|
|
28
|
+
_PRE_NATIVE = {"claimed", "publishing_topic", "binding_ready"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SchedulerUnavailable(ScheduleError):
|
|
32
|
+
code = "unavailable"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ScheduledTurnReader(Protocol):
|
|
36
|
+
async def read_scheduled_turn(
|
|
37
|
+
self, binding_id: str, turn_id: str, *, deadline: float | None = None,
|
|
38
|
+
) -> str: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Scheduler:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
bindings: BindingStore,
|
|
45
|
+
runtime: ScheduledTurnReader,
|
|
46
|
+
app_id: str,
|
|
47
|
+
dispatch: Callable[[Claim], Awaitable[None]],
|
|
48
|
+
wall_clock: Callable[[], float] = time.time,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._bindings = bindings
|
|
51
|
+
self._store = bindings.schedules
|
|
52
|
+
self._runtime = runtime
|
|
53
|
+
self._app_id = app_id
|
|
54
|
+
self._dispatch = dispatch
|
|
55
|
+
self._wall_clock = wall_clock
|
|
56
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
57
|
+
self._timer: asyncio.Task[None] | None = None
|
|
58
|
+
self._wake = asyncio.Event()
|
|
59
|
+
self._admission = False
|
|
60
|
+
self._closed = False
|
|
61
|
+
self._recovered = False
|
|
62
|
+
self._ticking = False
|
|
63
|
+
self._dispatches: dict[str, tuple[str, asyncio.Task[None]]] = {}
|
|
64
|
+
# Only exact Turns observed running after restart/explicit refresh.
|
|
65
|
+
# Fresh ordinary consumers are responsible for their own barriers.
|
|
66
|
+
self._recovered_running: dict[str, str] = {}
|
|
67
|
+
|
|
68
|
+
def _own_loop(self) -> asyncio.AbstractEventLoop:
|
|
69
|
+
loop = asyncio.get_running_loop()
|
|
70
|
+
if self._loop is None:
|
|
71
|
+
self._loop = loop
|
|
72
|
+
elif self._loop is not loop:
|
|
73
|
+
raise RuntimeError("Scheduler must run on its owning event loop")
|
|
74
|
+
return loop
|
|
75
|
+
|
|
76
|
+
async def recover(self) -> None:
|
|
77
|
+
"""Reconcile pending metadata once, then skip all offline due points."""
|
|
78
|
+
loop = self._own_loop()
|
|
79
|
+
if self._admission or self._closed or self._recovered:
|
|
80
|
+
raise RuntimeError("Scheduler recovery must precede admission exactly once")
|
|
81
|
+
self._store.abandon_pending_deliveries()
|
|
82
|
+
deadline = loop.time() + RECOVERY_TIMEOUT_SECONDS
|
|
83
|
+
for run in self._store.pending_runs():
|
|
84
|
+
if run.app_id != self._app_id:
|
|
85
|
+
continue
|
|
86
|
+
if run.target_kind == "binding":
|
|
87
|
+
self._store.release(run.id, error_code=(
|
|
88
|
+
"input_unknown" if run.phase in {"starting_turn", "handed_off"}
|
|
89
|
+
else "publishing_unknown" if run.phase == "publishing_topic"
|
|
90
|
+
else "recovery_no_start"
|
|
91
|
+
))
|
|
92
|
+
elif run.phase in _PRE_NATIVE:
|
|
93
|
+
self._store.release(run.id, error_code=(
|
|
94
|
+
"publishing_unknown" if run.phase == "publishing_topic"
|
|
95
|
+
else "recovery_no_start"
|
|
96
|
+
))
|
|
97
|
+
elif run.barrier != "unknown":
|
|
98
|
+
await self._observe(run, deadline=deadline)
|
|
99
|
+
# A persisted unknown is not retried by every service restart.
|
|
100
|
+
seen: set[str] = set()
|
|
101
|
+
while True:
|
|
102
|
+
plans = self._store.due_plans(app_id=self._app_id, now=self._wall_clock())
|
|
103
|
+
fresh = [plan for plan in plans if plan.id not in seen]
|
|
104
|
+
if not fresh:
|
|
105
|
+
break
|
|
106
|
+
for plan in fresh:
|
|
107
|
+
seen.add(plan.id)
|
|
108
|
+
try:
|
|
109
|
+
self._store.claim_due(plan.id, app_id=self._app_id, now=self._wall_clock(), recover=True)
|
|
110
|
+
except (ProjectDeleting, ScheduleNotFound):
|
|
111
|
+
continue
|
|
112
|
+
await asyncio.sleep(0)
|
|
113
|
+
self._recovered = True
|
|
114
|
+
|
|
115
|
+
def start(self) -> None:
|
|
116
|
+
self._own_loop()
|
|
117
|
+
if self._closed or self._timer is not None or not self._recovered:
|
|
118
|
+
raise RuntimeError("Recover Scheduler before starting it exactly once")
|
|
119
|
+
self._admission = True
|
|
120
|
+
self._timer = asyncio.create_task(self._run_timer(), name="schedule-timer")
|
|
121
|
+
|
|
122
|
+
def wake(self) -> None:
|
|
123
|
+
self._wake.set()
|
|
124
|
+
|
|
125
|
+
def close_admission(self) -> None:
|
|
126
|
+
self._admission = False
|
|
127
|
+
self.wake()
|
|
128
|
+
|
|
129
|
+
def run_now(
|
|
130
|
+
self, plan_id: str, expected_revision: int, request_id: str,
|
|
131
|
+
request_payload: object,
|
|
132
|
+
) -> MutationResult:
|
|
133
|
+
"""Claim and attach manual work without yielding or moving the timer cursor."""
|
|
134
|
+
self._own_loop()
|
|
135
|
+
if not self._admission:
|
|
136
|
+
raise SchedulerUnavailable("调度服务尚未就绪或正在停止,暂不能立即运行。")
|
|
137
|
+
result, claim = self._store.claim_manual(
|
|
138
|
+
plan_id, app_id=self._app_id, expected_revision=expected_revision,
|
|
139
|
+
request_id=request_id, request_payload=request_payload, now=self._wall_clock(),
|
|
140
|
+
)
|
|
141
|
+
if claim is not None:
|
|
142
|
+
self._dispatch_claim(claim)
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
def _dispatch_claim(self, claim: Claim) -> None:
|
|
146
|
+
task = asyncio.create_task(self._dispatch_once(claim), name="schedule-dispatch:" + claim.run.id)
|
|
147
|
+
self._dispatches[claim.run.id] = (claim.run.project_alias, task)
|
|
148
|
+
task.add_done_callback(lambda done, run_id=claim.run.id: self._dispatch_done(run_id, done))
|
|
149
|
+
|
|
150
|
+
async def close(self) -> None:
|
|
151
|
+
"""Stop the timer; shutdown dispatch draining remains a separate step."""
|
|
152
|
+
self.close_admission()
|
|
153
|
+
self._closed = True
|
|
154
|
+
if self._timer is not None:
|
|
155
|
+
self._timer.cancel()
|
|
156
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
157
|
+
await self._timer
|
|
158
|
+
|
|
159
|
+
async def _run_timer(self) -> None:
|
|
160
|
+
while self._admission:
|
|
161
|
+
self._wake.clear()
|
|
162
|
+
now = self._wall_clock()
|
|
163
|
+
next_tick = now + TICK_INTERVAL_SECONDS - now % TICK_INTERVAL_SECONDS
|
|
164
|
+
try:
|
|
165
|
+
await self.tick()
|
|
166
|
+
except asyncio.CancelledError:
|
|
167
|
+
raise
|
|
168
|
+
except Exception as error:
|
|
169
|
+
# A Store/controller failure must not keep creating work.
|
|
170
|
+
self.close_admission()
|
|
171
|
+
logger.error("Schedule timer stopped", extra={"error_type": type(error).__name__})
|
|
172
|
+
return
|
|
173
|
+
if self._admission:
|
|
174
|
+
try:
|
|
175
|
+
# Keep the minute boundary chosen before work, so a scan
|
|
176
|
+
# crossing that boundary does not postpone newly due plans.
|
|
177
|
+
delay = min(TICK_INTERVAL_SECONDS, max(0.0, next_tick - self._wall_clock()))
|
|
178
|
+
await asyncio.wait_for(self._wake.wait(), delay)
|
|
179
|
+
except TimeoutError:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
async def tick(self) -> int:
|
|
183
|
+
"""Claim one bounded batch; return without waiting for dispatch or Turn."""
|
|
184
|
+
loop = self._own_loop()
|
|
185
|
+
if not self._admission or self._ticking:
|
|
186
|
+
return 0
|
|
187
|
+
self._ticking = True
|
|
188
|
+
claimed = 0
|
|
189
|
+
processed = 0
|
|
190
|
+
try:
|
|
191
|
+
deadline = loop.time() + RECOVERY_TIMEOUT_SECONDS
|
|
192
|
+
plans = self._store.due_plans(app_id=self._app_id, now=self._wall_clock(), limit=DUE_BATCH_SIZE)
|
|
193
|
+
for plan in plans:
|
|
194
|
+
if not self._admission:
|
|
195
|
+
break
|
|
196
|
+
pending = self._store.pending_for_plan(plan.id)
|
|
197
|
+
if pending is None:
|
|
198
|
+
self._recovered_running.pop(plan.id, None)
|
|
199
|
+
elif self._recovered_running.get(plan.id) == pending.id:
|
|
200
|
+
await self._observe(pending, deadline=deadline)
|
|
201
|
+
if not self._admission:
|
|
202
|
+
break
|
|
203
|
+
try:
|
|
204
|
+
claim = self._store.claim_due(plan.id, app_id=self._app_id, now=self._wall_clock())
|
|
205
|
+
except (ProjectDeleting, ScheduleNotFound):
|
|
206
|
+
continue
|
|
207
|
+
processed += 1
|
|
208
|
+
if claim is not None:
|
|
209
|
+
self._dispatch_claim(claim)
|
|
210
|
+
claimed += 1
|
|
211
|
+
# Drain full batches without another minute's delay, including
|
|
212
|
+
# skipped occurrences. Do not spin on frozen Project deletions.
|
|
213
|
+
if len(plans) == DUE_BATCH_SIZE and processed:
|
|
214
|
+
self.wake()
|
|
215
|
+
return claimed
|
|
216
|
+
finally:
|
|
217
|
+
self._ticking = False
|
|
218
|
+
|
|
219
|
+
async def refresh(self, plan_id: str) -> str | None:
|
|
220
|
+
"""Explicit user recheck; ordinary Runtime observation guards still apply."""
|
|
221
|
+
loop = self._own_loop()
|
|
222
|
+
run = self._store.pending_for_plan(plan_id)
|
|
223
|
+
if run is None or run.app_id != self._app_id:
|
|
224
|
+
self._recovered_running.pop(plan_id, None)
|
|
225
|
+
return None
|
|
226
|
+
if run.id in self._dispatches or run.phase in _PRE_NATIVE:
|
|
227
|
+
return None
|
|
228
|
+
if run.target_kind == "binding":
|
|
229
|
+
self._store.release(run.id, error_code="input_unknown")
|
|
230
|
+
return None
|
|
231
|
+
return await self._observe(run, deadline=loop.time() + READ_TIMEOUT_SECONDS)
|
|
232
|
+
|
|
233
|
+
async def _observe(self, run: Run, *, deadline: float) -> str | None:
|
|
234
|
+
if run.target_kind == "binding":
|
|
235
|
+
return None
|
|
236
|
+
loop = self._own_loop()
|
|
237
|
+
self._recovered_running.pop(run.plan_id, None)
|
|
238
|
+
try:
|
|
239
|
+
current = self._store.get_run(run.id)
|
|
240
|
+
except ScheduleNotFound:
|
|
241
|
+
return None
|
|
242
|
+
if current.barrier == "released":
|
|
243
|
+
return None
|
|
244
|
+
if not current.binding_id or not current.initial_turn_id:
|
|
245
|
+
self._store.set_run(current.id, barrier="unknown", error_code="initial_reference_unknown")
|
|
246
|
+
return None
|
|
247
|
+
if loop.time() >= deadline:
|
|
248
|
+
self._store.set_run(current.id, barrier="unknown", error_code="read_timeout")
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
async with asyncio.timeout_at(min(deadline, loop.time() + READ_TIMEOUT_SECONDS)):
|
|
252
|
+
status = await self._runtime.read_scheduled_turn(
|
|
253
|
+
current.binding_id, current.initial_turn_id,
|
|
254
|
+
deadline=min(deadline, loop.time() + READ_TIMEOUT_SECONDS),
|
|
255
|
+
)
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
# Shutdown is not native terminal evidence. No later timer is left
|
|
258
|
+
# to repeat the cancelled observation automatically.
|
|
259
|
+
with contextlib.suppress(ScheduleNotFound):
|
|
260
|
+
self._store.set_run(current.id, barrier="unknown", error_code="read_cancelled")
|
|
261
|
+
raise
|
|
262
|
+
except Exception as error:
|
|
263
|
+
code = getattr(error, "code", None)
|
|
264
|
+
if not isinstance(code, str):
|
|
265
|
+
code = "read_timeout" if isinstance(error, TimeoutError) else "read_unavailable"
|
|
266
|
+
with contextlib.suppress(ScheduleNotFound):
|
|
267
|
+
self._store.set_run(current.id, barrier="unknown", error_code=code)
|
|
268
|
+
return None
|
|
269
|
+
# Re-read after await: terminal/lifecycle proof may already have released
|
|
270
|
+
# this barrier. Store never reopens a released occurrence.
|
|
271
|
+
try:
|
|
272
|
+
current = self._store.get_run(current.id)
|
|
273
|
+
except ScheduleNotFound:
|
|
274
|
+
# Completion/lifecycle may have released and pruned it while this
|
|
275
|
+
# read was outstanding. There is no longer a barrier to change.
|
|
276
|
+
return None
|
|
277
|
+
if current.barrier == "released":
|
|
278
|
+
return status if status in _TERMINAL else None
|
|
279
|
+
if status in _TERMINAL:
|
|
280
|
+
self._store.release(current.id)
|
|
281
|
+
return status
|
|
282
|
+
if status == "inProgress":
|
|
283
|
+
current = self._store.set_run(current.id, barrier="held", error_code=None)
|
|
284
|
+
self._recovered_running[current.plan_id] = current.id
|
|
285
|
+
return status
|
|
286
|
+
self._store.set_run(current.id, barrier="unknown", error_code="status_unavailable")
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
async def _dispatch_once(self, claim: Claim) -> None:
|
|
290
|
+
try:
|
|
291
|
+
timeout = BINDING_DISPATCH_TIMEOUT_SECONDS if claim.run.target_kind == "binding" else DISPATCH_TIMEOUT_SECONDS
|
|
292
|
+
async with asyncio.timeout(timeout):
|
|
293
|
+
await self._dispatch(claim)
|
|
294
|
+
except asyncio.CancelledError:
|
|
295
|
+
self._settle_dispatch(claim.run.id, "dispatch_cancelled")
|
|
296
|
+
raise
|
|
297
|
+
except Exception as error:
|
|
298
|
+
self._settle_dispatch(claim.run.id, "dispatch_timeout" if isinstance(error, TimeoutError) else "dispatch_failed")
|
|
299
|
+
logger.warning("Scheduled dispatch did not finish", extra={"run_id": claim.run.id, "error_type": type(error).__name__})
|
|
300
|
+
else:
|
|
301
|
+
try:
|
|
302
|
+
run = self._store.get_run(claim.run.id)
|
|
303
|
+
except ScheduleNotFound:
|
|
304
|
+
return
|
|
305
|
+
if run.target_kind == "binding" and run.barrier != "released":
|
|
306
|
+
self._settle_dispatch(run.id, "dispatch_incomplete")
|
|
307
|
+
elif run.barrier == "held" and (
|
|
308
|
+
run.phase != "handed_off" or not run.binding_id or not run.initial_turn_id
|
|
309
|
+
):
|
|
310
|
+
self._settle_dispatch(run.id, "dispatch_incomplete")
|
|
311
|
+
|
|
312
|
+
def _settle_dispatch(self, run_id: str, reason: str) -> None:
|
|
313
|
+
try:
|
|
314
|
+
run = self._store.get_run(run_id)
|
|
315
|
+
except ScheduleNotFound:
|
|
316
|
+
return
|
|
317
|
+
if run.barrier == "released":
|
|
318
|
+
return
|
|
319
|
+
if run.target_kind == "binding":
|
|
320
|
+
self._store.release(run_id, error_code=(
|
|
321
|
+
"input_unknown" if run.phase in {"starting_turn", "handed_off"}
|
|
322
|
+
else "publishing_unknown" if run.phase == "publishing_topic"
|
|
323
|
+
else reason
|
|
324
|
+
))
|
|
325
|
+
return
|
|
326
|
+
if run.barrier == "unknown":
|
|
327
|
+
return
|
|
328
|
+
if run.phase in _PRE_NATIVE:
|
|
329
|
+
self._store.release(run_id, error_code="publishing_unknown" if run.phase == "publishing_topic" else reason)
|
|
330
|
+
else:
|
|
331
|
+
self._store.set_run(run_id, barrier="unknown", error_code=reason)
|
|
332
|
+
|
|
333
|
+
def _dispatch_done(self, run_id: str, task: asyncio.Task[None]) -> None:
|
|
334
|
+
self._dispatches.pop(run_id, None)
|
|
335
|
+
if not task.cancelled() and task.exception() is not None:
|
|
336
|
+
self.close_admission()
|
|
337
|
+
logger.error("Scheduled dispatch state could not be saved", extra={"run_id": run_id})
|
|
338
|
+
|
|
339
|
+
async def drain(self, deadline: float) -> bool:
|
|
340
|
+
"""Bound shutdown creation work; never cancel a handed-off native Turn."""
|
|
341
|
+
loop = self._own_loop()
|
|
342
|
+
tasks = {task for _, task in self._dispatches.values() if not task.done()}
|
|
343
|
+
if not tasks:
|
|
344
|
+
return True
|
|
345
|
+
try:
|
|
346
|
+
_, pending = await asyncio.wait(tasks, timeout=max(0, deadline - loop.time()))
|
|
347
|
+
except asyncio.CancelledError:
|
|
348
|
+
await self._cancel_dispatches({task for task in tasks if not task.done()})
|
|
349
|
+
raise
|
|
350
|
+
if not pending:
|
|
351
|
+
return True
|
|
352
|
+
await self._cancel_dispatches(pending)
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
async def _cancel_dispatches(self, pending: set[asyncio.Task[None]]) -> None:
|
|
356
|
+
if not pending:
|
|
357
|
+
return
|
|
358
|
+
for task in pending:
|
|
359
|
+
task.cancel()
|
|
360
|
+
# Save uncertainty before yielding, including when the outer service
|
|
361
|
+
# cleanup budget cancelled drain before its own deadline elapsed.
|
|
362
|
+
for run_id, (_, task) in tuple(self._dispatches.items()):
|
|
363
|
+
if task in pending and not task.done():
|
|
364
|
+
self._settle_dispatch(run_id, "dispatch_cancelled")
|
|
365
|
+
await asyncio.wait(pending, timeout=0.1)
|
|
366
|
+
|
|
367
|
+
async def drain_project_creation(self, alias: str, deadline: float) -> bool:
|
|
368
|
+
"""Project deletion has already frozen new claims; only observe tasks."""
|
|
369
|
+
loop = self._own_loop()
|
|
370
|
+
tasks = {task for project, task in self._dispatches.values() if project == alias and not task.done()}
|
|
371
|
+
if not tasks:
|
|
372
|
+
return True
|
|
373
|
+
_, pending = await asyncio.wait(tasks, timeout=max(0, deadline - loop.time()))
|
|
374
|
+
return not pending
|