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,1505 @@
|
|
|
1
|
+
"""One best-effort owner for ordinary, Side, and Goal Reply Card delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from lark_channel import OutboundCard
|
|
12
|
+
|
|
13
|
+
from ..cards import CardActionError, reply_card, turn_progress_card
|
|
14
|
+
from ..codex_runtime import CodexRuntime
|
|
15
|
+
from ..runtime.contracts import (
|
|
16
|
+
SideTurnActivitySnapshot,
|
|
17
|
+
TurnActivitySnapshot,
|
|
18
|
+
)
|
|
19
|
+
from ..domain import FeishuScope, GoalStatus, ReplyCardGoalModule, ReplyCardProjection
|
|
20
|
+
from .messages import _object_field, _progress_card_message_id, _send_result_error_code
|
|
21
|
+
from .ports import ReplyChannel
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_PROGRESS_CARD_POLL_SECONDS = 1.0
|
|
27
|
+
_PROGRESS_CARD_OPERATION_TIMEOUT_SECONDS = 5.0
|
|
28
|
+
_PROGRESS_CARD_MAX_CONSECUTIVE_FAILURES = 3
|
|
29
|
+
_TERMINAL_CARD_UPDATE_ATTEMPTS = 3
|
|
30
|
+
_TERMINAL_CARD_RETRY_SECONDS = 0.5
|
|
31
|
+
_GOAL_REPLY_CARD_CACHE_LIMIT = 256
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _retained_goal_projection(projection: ReplyCardProjection) -> ReplyCardProjection:
|
|
35
|
+
"""Keep the delivered content without replaying its one-shot mention."""
|
|
36
|
+
|
|
37
|
+
result = projection.result
|
|
38
|
+
if result is None or result.completion_mention_user_id is None:
|
|
39
|
+
return projection
|
|
40
|
+
return replace(
|
|
41
|
+
projection,
|
|
42
|
+
result=replace(result, completion_mention_user_id=None),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class _CardUpdateAttempt:
|
|
48
|
+
"""One public update call, retaining its response for terminal handoff."""
|
|
49
|
+
|
|
50
|
+
message_id: str
|
|
51
|
+
result: object | None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def updated(self) -> bool:
|
|
55
|
+
# This is the existing presentation success projection, not proof of
|
|
56
|
+
# an exact destination or of a failed request having no side effects.
|
|
57
|
+
return self.result is not None and getattr(self.result, "success", True) is not False
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def confirmed(self) -> bool:
|
|
61
|
+
raw = _object_field(self.result, "raw")
|
|
62
|
+
code = _object_field(raw, "code")
|
|
63
|
+
return getattr(self.result, "success", None) is True and (code is None or code == 0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class GoalCardOrigin:
|
|
68
|
+
message_id: str | None
|
|
69
|
+
scope: FeishuScope
|
|
70
|
+
binding_id: str
|
|
71
|
+
short_id: str
|
|
72
|
+
project_alias: str
|
|
73
|
+
fallback_origin: object | None = None
|
|
74
|
+
goal_generation: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class _TurnProgressCardSession:
|
|
79
|
+
binding_id: str
|
|
80
|
+
thread_id: str
|
|
81
|
+
turn_id: str
|
|
82
|
+
message_id: str
|
|
83
|
+
stopped: asyncio.Event
|
|
84
|
+
snapshot: TurnActivitySnapshot
|
|
85
|
+
failed: bool = False
|
|
86
|
+
task: asyncio.Task[None] | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True)
|
|
90
|
+
class _SideTurnProgressCardSession:
|
|
91
|
+
side_id: str
|
|
92
|
+
thread_id: str
|
|
93
|
+
turn_id: str
|
|
94
|
+
message_id: str
|
|
95
|
+
stopped: asyncio.Event
|
|
96
|
+
snapshot: SideTurnActivitySnapshot
|
|
97
|
+
failed: bool = False
|
|
98
|
+
task: asyncio.Task[None] | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class _GoalReplyCardSession:
|
|
103
|
+
binding_id: str
|
|
104
|
+
thread_id: str
|
|
105
|
+
goal_generation: str
|
|
106
|
+
logical_turn_id: str
|
|
107
|
+
message_id: str
|
|
108
|
+
stopped: asyncio.Event
|
|
109
|
+
projection: ReplyCardProjection
|
|
110
|
+
revision: object
|
|
111
|
+
refresh: Callable[
|
|
112
|
+
[],
|
|
113
|
+
Awaitable[tuple[object, ReplyCardProjection] | None],
|
|
114
|
+
] | None = None
|
|
115
|
+
failed: bool = False
|
|
116
|
+
task: asyncio.Task[None] | None = None
|
|
117
|
+
|
|
118
|
+
def __post_init__(self) -> None:
|
|
119
|
+
self.projection = _retained_goal_projection(self.projection)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _GoalCardDelivery(Enum):
|
|
123
|
+
NOT_ATTEMPTED = "not-attempted"
|
|
124
|
+
DELIVERED = "delivered"
|
|
125
|
+
SUPERSEDED = "superseded"
|
|
126
|
+
FAILED = "failed"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class _GoalCardReceipt:
|
|
131
|
+
status: _GoalCardDelivery
|
|
132
|
+
message_id: str | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _ReplyCardPresenter:
|
|
136
|
+
"""One best-effort owner for Turn, Side Turn, and Goal Reply Cards."""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
channel: ReplyChannel,
|
|
141
|
+
runtime: CodexRuntime,
|
|
142
|
+
*,
|
|
143
|
+
poll_seconds: float = _PROGRESS_CARD_POLL_SECONDS,
|
|
144
|
+
operation_timeout_seconds: float = _PROGRESS_CARD_OPERATION_TIMEOUT_SECONDS,
|
|
145
|
+
) -> None:
|
|
146
|
+
if poll_seconds <= 0:
|
|
147
|
+
raise ValueError("progress card poll interval must be positive")
|
|
148
|
+
if operation_timeout_seconds <= 0:
|
|
149
|
+
raise ValueError("progress card operation timeout must be positive")
|
|
150
|
+
self._channel = channel
|
|
151
|
+
self._runtime = runtime
|
|
152
|
+
self._poll_seconds = poll_seconds
|
|
153
|
+
self._operation_timeout_seconds = operation_timeout_seconds
|
|
154
|
+
self._sessions: dict[
|
|
155
|
+
tuple[str, str, str],
|
|
156
|
+
_TurnProgressCardSession,
|
|
157
|
+
] = {}
|
|
158
|
+
self._side_sessions: dict[
|
|
159
|
+
tuple[str, str, str],
|
|
160
|
+
_SideTurnProgressCardSession,
|
|
161
|
+
] = {}
|
|
162
|
+
self._goal_sessions: dict[
|
|
163
|
+
tuple[str, str, str],
|
|
164
|
+
_GoalReplyCardSession,
|
|
165
|
+
] = {}
|
|
166
|
+
self._goal_cards: dict[
|
|
167
|
+
tuple[str, str],
|
|
168
|
+
ReplyCardProjection,
|
|
169
|
+
] = {}
|
|
170
|
+
self._goal_lock = asyncio.Lock()
|
|
171
|
+
self._goal_card_lock = asyncio.Lock()
|
|
172
|
+
self._retired_goal_runs: set[tuple[str, str, str]] = set()
|
|
173
|
+
self._goal_latest_runs: dict[tuple[str, str, str], str] = {}
|
|
174
|
+
self._closed = False
|
|
175
|
+
|
|
176
|
+
async def start(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
binding_id: str,
|
|
180
|
+
thread_id: str,
|
|
181
|
+
turn_id: str,
|
|
182
|
+
origin: object,
|
|
183
|
+
reply: Callable[[OutboundCard], Awaitable[object]] | None = None,
|
|
184
|
+
validate_reply: Callable[[object], Awaitable[bool]] | None = None,
|
|
185
|
+
) -> bool:
|
|
186
|
+
if self._closed:
|
|
187
|
+
return False
|
|
188
|
+
try:
|
|
189
|
+
snapshot = self._runtime.turn_activity(
|
|
190
|
+
binding_id,
|
|
191
|
+
thread_id=thread_id,
|
|
192
|
+
turn_id=turn_id,
|
|
193
|
+
refresh_plan=False,
|
|
194
|
+
)
|
|
195
|
+
except Exception:
|
|
196
|
+
logger.exception(
|
|
197
|
+
"failed to read initial progress-card activity",
|
|
198
|
+
extra={"binding_id": binding_id, "turn_id": turn_id},
|
|
199
|
+
)
|
|
200
|
+
return False
|
|
201
|
+
if snapshot is None:
|
|
202
|
+
logger.error(
|
|
203
|
+
"failed to start progress card: exact Turn activity unavailable",
|
|
204
|
+
extra={
|
|
205
|
+
"binding_id": binding_id,
|
|
206
|
+
"thread_id": thread_id,
|
|
207
|
+
"turn_id": turn_id,
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
return False
|
|
211
|
+
try:
|
|
212
|
+
card = turn_progress_card(snapshot=snapshot)
|
|
213
|
+
async with asyncio.timeout(self._operation_timeout_seconds):
|
|
214
|
+
result = (
|
|
215
|
+
await self._channel.reply(origin, card)
|
|
216
|
+
if reply is None
|
|
217
|
+
else await reply(card)
|
|
218
|
+
)
|
|
219
|
+
if validate_reply is not None and not await validate_reply(result):
|
|
220
|
+
return False
|
|
221
|
+
except asyncio.CancelledError:
|
|
222
|
+
raise
|
|
223
|
+
except Exception:
|
|
224
|
+
logger.exception(
|
|
225
|
+
"failed to send initial progress card",
|
|
226
|
+
extra={"binding_id": binding_id, "turn_id": turn_id},
|
|
227
|
+
)
|
|
228
|
+
return False
|
|
229
|
+
message_id = _progress_card_message_id(result)
|
|
230
|
+
if message_id is None:
|
|
231
|
+
logger.error(
|
|
232
|
+
"failed to start progress card: reply message ID unavailable",
|
|
233
|
+
extra={"binding_id": binding_id, "turn_id": turn_id},
|
|
234
|
+
)
|
|
235
|
+
return False
|
|
236
|
+
current = self._runtime.turn_activity(
|
|
237
|
+
binding_id,
|
|
238
|
+
thread_id=thread_id,
|
|
239
|
+
turn_id=turn_id,
|
|
240
|
+
refresh_plan=False,
|
|
241
|
+
)
|
|
242
|
+
if (
|
|
243
|
+
current is None
|
|
244
|
+
or self._runtime.lifecycle_state(binding_id) is not None
|
|
245
|
+
):
|
|
246
|
+
return False
|
|
247
|
+
key = (binding_id, thread_id, turn_id)
|
|
248
|
+
previous = self._sessions.pop(key, None)
|
|
249
|
+
if previous is not None:
|
|
250
|
+
await self._stop_session(previous)
|
|
251
|
+
session = _TurnProgressCardSession(
|
|
252
|
+
binding_id=binding_id,
|
|
253
|
+
thread_id=thread_id,
|
|
254
|
+
turn_id=turn_id,
|
|
255
|
+
message_id=message_id,
|
|
256
|
+
stopped=asyncio.Event(),
|
|
257
|
+
snapshot=snapshot,
|
|
258
|
+
)
|
|
259
|
+
self._sessions[key] = session
|
|
260
|
+
session.task = asyncio.create_task(
|
|
261
|
+
self._poll(session),
|
|
262
|
+
name=f"netizen-progress-card-{turn_id}",
|
|
263
|
+
)
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
async def finish(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
binding_id: str,
|
|
270
|
+
thread_id: str,
|
|
271
|
+
turn_id: str,
|
|
272
|
+
activity: TurnActivitySnapshot | None,
|
|
273
|
+
render: Callable[[TurnActivitySnapshot], OutboundCard],
|
|
274
|
+
) -> _CardUpdateAttempt | None:
|
|
275
|
+
session = self._sessions.pop((binding_id, thread_id, turn_id), None)
|
|
276
|
+
if session is None:
|
|
277
|
+
return None
|
|
278
|
+
await self._stop_session(session)
|
|
279
|
+
snapshot = activity or session.snapshot
|
|
280
|
+
if (
|
|
281
|
+
snapshot.binding_id != session.binding_id
|
|
282
|
+
or snapshot.thread_id != session.thread_id
|
|
283
|
+
or snapshot.turn_id != session.turn_id
|
|
284
|
+
):
|
|
285
|
+
logger.error(
|
|
286
|
+
"failed to finish progress card: activity identity mismatch",
|
|
287
|
+
extra={
|
|
288
|
+
"binding_id": session.binding_id,
|
|
289
|
+
"turn_id": session.turn_id,
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
card = render(snapshot)
|
|
295
|
+
except asyncio.CancelledError:
|
|
296
|
+
raise
|
|
297
|
+
except Exception:
|
|
298
|
+
logger.exception(
|
|
299
|
+
"failed to render terminal progress card",
|
|
300
|
+
extra={
|
|
301
|
+
"binding_id": session.binding_id,
|
|
302
|
+
"turn_id": session.turn_id,
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
return None
|
|
306
|
+
return await self._update_terminal_message(
|
|
307
|
+
session.message_id,
|
|
308
|
+
card,
|
|
309
|
+
binding_id=session.binding_id,
|
|
310
|
+
operation_id=session.turn_id,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
async def abandon(
|
|
314
|
+
self,
|
|
315
|
+
*,
|
|
316
|
+
binding_id: str,
|
|
317
|
+
thread_id: str,
|
|
318
|
+
turn_id: str,
|
|
319
|
+
) -> None:
|
|
320
|
+
session = self._sessions.pop((binding_id, thread_id, turn_id), None)
|
|
321
|
+
if session is not None:
|
|
322
|
+
await self._stop_session(session)
|
|
323
|
+
|
|
324
|
+
async def abandon_thread(
|
|
325
|
+
self,
|
|
326
|
+
*,
|
|
327
|
+
binding_id: str,
|
|
328
|
+
thread_id: str,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Stop every ordinary/Goal presenter owned by one removed Thread."""
|
|
331
|
+
|
|
332
|
+
ordinary_keys = tuple(
|
|
333
|
+
key
|
|
334
|
+
for key in self._sessions
|
|
335
|
+
if key[0] == binding_id and key[1] == thread_id
|
|
336
|
+
)
|
|
337
|
+
for key in ordinary_keys:
|
|
338
|
+
session = self._sessions.pop(key, None)
|
|
339
|
+
if session is not None:
|
|
340
|
+
await self._stop_session(session)
|
|
341
|
+
|
|
342
|
+
async with self._goal_lock:
|
|
343
|
+
goal_keys = tuple(
|
|
344
|
+
key
|
|
345
|
+
for key in self._goal_sessions
|
|
346
|
+
if key[0] == binding_id and key[1] == thread_id
|
|
347
|
+
)
|
|
348
|
+
self._goal_latest_runs = {
|
|
349
|
+
key: run_id
|
|
350
|
+
for key, run_id in self._goal_latest_runs.items()
|
|
351
|
+
if key[:2] != (binding_id, thread_id)
|
|
352
|
+
}
|
|
353
|
+
removed: list[_GoalReplyCardSession] = []
|
|
354
|
+
for key in goal_keys:
|
|
355
|
+
session = self._goal_sessions.pop(key, None)
|
|
356
|
+
if session is not None:
|
|
357
|
+
removed.append(session)
|
|
358
|
+
await self._stop_session(session)
|
|
359
|
+
if removed:
|
|
360
|
+
async with self._goal_card_lock:
|
|
361
|
+
for session in removed:
|
|
362
|
+
self._goal_cards.pop(
|
|
363
|
+
(session.message_id, session.goal_generation),
|
|
364
|
+
None,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def park_unavailable(
|
|
368
|
+
self,
|
|
369
|
+
*,
|
|
370
|
+
binding_id: str,
|
|
371
|
+
thread_id: str,
|
|
372
|
+
turn_id: str,
|
|
373
|
+
) -> bool:
|
|
374
|
+
"""Render one unavailable snapshot and stop all future card polling."""
|
|
375
|
+
|
|
376
|
+
session = self._sessions.pop((binding_id, thread_id, turn_id), None)
|
|
377
|
+
if session is None:
|
|
378
|
+
return False
|
|
379
|
+
await self._stop_session(session)
|
|
380
|
+
if session.failed:
|
|
381
|
+
return False
|
|
382
|
+
try:
|
|
383
|
+
snapshot = self._runtime.turn_activity(
|
|
384
|
+
binding_id,
|
|
385
|
+
thread_id=thread_id,
|
|
386
|
+
turn_id=turn_id,
|
|
387
|
+
refresh_plan=False,
|
|
388
|
+
)
|
|
389
|
+
except Exception:
|
|
390
|
+
logger.exception(
|
|
391
|
+
"failed to read unavailable progress-card activity",
|
|
392
|
+
extra={"binding_id": binding_id, "turn_id": turn_id},
|
|
393
|
+
)
|
|
394
|
+
return False
|
|
395
|
+
if snapshot is None:
|
|
396
|
+
return False
|
|
397
|
+
try:
|
|
398
|
+
return await self._update(
|
|
399
|
+
session,
|
|
400
|
+
turn_progress_card(snapshot=snapshot),
|
|
401
|
+
)
|
|
402
|
+
except asyncio.CancelledError:
|
|
403
|
+
raise
|
|
404
|
+
except Exception:
|
|
405
|
+
logger.exception(
|
|
406
|
+
"failed to render unavailable progress card",
|
|
407
|
+
extra={"binding_id": binding_id, "turn_id": turn_id},
|
|
408
|
+
)
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
async def start_side(
|
|
412
|
+
self,
|
|
413
|
+
*,
|
|
414
|
+
side_id: str,
|
|
415
|
+
thread_id: str,
|
|
416
|
+
turn_id: str,
|
|
417
|
+
origin: object,
|
|
418
|
+
reply: Callable[[OutboundCard], Awaitable[object]] | None = None,
|
|
419
|
+
) -> bool:
|
|
420
|
+
if self._closed:
|
|
421
|
+
return False
|
|
422
|
+
try:
|
|
423
|
+
snapshot = self._runtime.side_turn_activity(
|
|
424
|
+
side_id,
|
|
425
|
+
thread_id=thread_id,
|
|
426
|
+
turn_id=turn_id,
|
|
427
|
+
refresh_plan=False,
|
|
428
|
+
)
|
|
429
|
+
except Exception:
|
|
430
|
+
logger.exception(
|
|
431
|
+
"failed to read initial Side progress-card activity",
|
|
432
|
+
extra={"side_id": side_id, "turn_id": turn_id},
|
|
433
|
+
)
|
|
434
|
+
return False
|
|
435
|
+
if snapshot is None:
|
|
436
|
+
logger.error(
|
|
437
|
+
"failed to start Side progress card: exact activity unavailable",
|
|
438
|
+
extra={
|
|
439
|
+
"side_id": side_id,
|
|
440
|
+
"thread_id": thread_id,
|
|
441
|
+
"turn_id": turn_id,
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
return False
|
|
445
|
+
try:
|
|
446
|
+
card = turn_progress_card(snapshot=snapshot)
|
|
447
|
+
async with asyncio.timeout(self._operation_timeout_seconds):
|
|
448
|
+
result = (
|
|
449
|
+
await self._channel.reply(origin, card)
|
|
450
|
+
if reply is None
|
|
451
|
+
else await reply(card)
|
|
452
|
+
)
|
|
453
|
+
except asyncio.CancelledError:
|
|
454
|
+
raise
|
|
455
|
+
except Exception:
|
|
456
|
+
logger.exception(
|
|
457
|
+
"failed to send initial Side progress card",
|
|
458
|
+
extra={"side_id": side_id, "turn_id": turn_id},
|
|
459
|
+
)
|
|
460
|
+
return False
|
|
461
|
+
message_id = _progress_card_message_id(result)
|
|
462
|
+
if message_id is None:
|
|
463
|
+
logger.error(
|
|
464
|
+
"failed to start Side progress card: reply message ID unavailable",
|
|
465
|
+
extra={"side_id": side_id, "turn_id": turn_id},
|
|
466
|
+
)
|
|
467
|
+
return False
|
|
468
|
+
key = (side_id, thread_id, turn_id)
|
|
469
|
+
previous = self._side_sessions.pop(key, None)
|
|
470
|
+
if previous is not None:
|
|
471
|
+
await self._stop_session(previous)
|
|
472
|
+
session = _SideTurnProgressCardSession(
|
|
473
|
+
side_id=side_id,
|
|
474
|
+
thread_id=thread_id,
|
|
475
|
+
turn_id=turn_id,
|
|
476
|
+
message_id=message_id,
|
|
477
|
+
stopped=asyncio.Event(),
|
|
478
|
+
snapshot=snapshot,
|
|
479
|
+
)
|
|
480
|
+
self._side_sessions[key] = session
|
|
481
|
+
session.task = asyncio.create_task(
|
|
482
|
+
self._poll_side(session),
|
|
483
|
+
name=f"netizen-side-progress-card-{turn_id}",
|
|
484
|
+
)
|
|
485
|
+
return True
|
|
486
|
+
|
|
487
|
+
async def finish_side(
|
|
488
|
+
self,
|
|
489
|
+
*,
|
|
490
|
+
side_id: str,
|
|
491
|
+
thread_id: str,
|
|
492
|
+
turn_id: str,
|
|
493
|
+
activity: SideTurnActivitySnapshot | None,
|
|
494
|
+
render: Callable[[SideTurnActivitySnapshot], OutboundCard],
|
|
495
|
+
) -> _CardUpdateAttempt | None:
|
|
496
|
+
session = self._side_sessions.pop((side_id, thread_id, turn_id), None)
|
|
497
|
+
if session is None:
|
|
498
|
+
return None
|
|
499
|
+
await self._stop_session(session)
|
|
500
|
+
snapshot = activity or session.snapshot
|
|
501
|
+
if (
|
|
502
|
+
snapshot.side_id != session.side_id
|
|
503
|
+
or snapshot.thread_id != session.thread_id
|
|
504
|
+
or snapshot.turn_id != session.turn_id
|
|
505
|
+
):
|
|
506
|
+
logger.error(
|
|
507
|
+
"failed to finish Side progress card: activity identity mismatch",
|
|
508
|
+
extra={"side_id": session.side_id, "turn_id": session.turn_id},
|
|
509
|
+
)
|
|
510
|
+
return None
|
|
511
|
+
try:
|
|
512
|
+
card = render(snapshot)
|
|
513
|
+
except asyncio.CancelledError:
|
|
514
|
+
raise
|
|
515
|
+
except Exception:
|
|
516
|
+
logger.exception(
|
|
517
|
+
"failed to render terminal Side progress card",
|
|
518
|
+
extra={"side_id": session.side_id, "turn_id": session.turn_id},
|
|
519
|
+
)
|
|
520
|
+
return None
|
|
521
|
+
return await self._update_terminal_message(
|
|
522
|
+
session.message_id,
|
|
523
|
+
card,
|
|
524
|
+
binding_id=f"side:{session.side_id}",
|
|
525
|
+
operation_id=session.turn_id,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
async def abandon_side(
|
|
529
|
+
self,
|
|
530
|
+
*,
|
|
531
|
+
side_id: str,
|
|
532
|
+
thread_id: str,
|
|
533
|
+
turn_id: str,
|
|
534
|
+
) -> None:
|
|
535
|
+
session = self._side_sessions.pop((side_id, thread_id, turn_id), None)
|
|
536
|
+
if session is not None:
|
|
537
|
+
await self._stop_session(session)
|
|
538
|
+
|
|
539
|
+
async def start_goal(
|
|
540
|
+
self,
|
|
541
|
+
*,
|
|
542
|
+
binding_id: str,
|
|
543
|
+
thread_id: str,
|
|
544
|
+
logical_turn_id: str,
|
|
545
|
+
generation: str,
|
|
546
|
+
origin: GoalCardOrigin,
|
|
547
|
+
projection: ReplyCardProjection,
|
|
548
|
+
revision: object,
|
|
549
|
+
refresh: Callable[
|
|
550
|
+
[],
|
|
551
|
+
Awaitable[tuple[object, ReplyCardProjection] | None],
|
|
552
|
+
]
|
|
553
|
+
| None,
|
|
554
|
+
) -> bool:
|
|
555
|
+
async with self._goal_lock:
|
|
556
|
+
return await self._start_goal_locked(
|
|
557
|
+
binding_id=binding_id,
|
|
558
|
+
thread_id=thread_id,
|
|
559
|
+
logical_turn_id=logical_turn_id,
|
|
560
|
+
generation=generation,
|
|
561
|
+
origin=origin,
|
|
562
|
+
projection=projection,
|
|
563
|
+
revision=revision,
|
|
564
|
+
refresh=refresh,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
async def _start_goal_locked(
|
|
568
|
+
self,
|
|
569
|
+
*,
|
|
570
|
+
binding_id: str,
|
|
571
|
+
thread_id: str,
|
|
572
|
+
logical_turn_id: str,
|
|
573
|
+
generation: str,
|
|
574
|
+
origin: GoalCardOrigin,
|
|
575
|
+
projection: ReplyCardProjection,
|
|
576
|
+
revision: object,
|
|
577
|
+
refresh: Callable[
|
|
578
|
+
[],
|
|
579
|
+
Awaitable[tuple[object, ReplyCardProjection] | None],
|
|
580
|
+
]
|
|
581
|
+
| None,
|
|
582
|
+
) -> bool:
|
|
583
|
+
if self._closed:
|
|
584
|
+
return False
|
|
585
|
+
key = (binding_id, thread_id, generation)
|
|
586
|
+
self._goal_latest_runs = {
|
|
587
|
+
existing: run_id
|
|
588
|
+
for existing, run_id in self._goal_latest_runs.items()
|
|
589
|
+
if existing[:2] != (binding_id, thread_id) or existing == key
|
|
590
|
+
}
|
|
591
|
+
self._goal_latest_runs[key] = logical_turn_id
|
|
592
|
+
previous = self._goal_sessions.pop(key, None)
|
|
593
|
+
message_id = origin.message_id
|
|
594
|
+
if previous is not None:
|
|
595
|
+
await self._stop_session(previous)
|
|
596
|
+
if not previous.failed:
|
|
597
|
+
message_id = previous.message_id
|
|
598
|
+
try:
|
|
599
|
+
card = reply_card(projection)
|
|
600
|
+
if message_id is None:
|
|
601
|
+
fallback = origin.fallback_origin
|
|
602
|
+
if fallback is None:
|
|
603
|
+
return False
|
|
604
|
+
async with asyncio.timeout(self._operation_timeout_seconds):
|
|
605
|
+
result = await self._channel.reply(fallback, card)
|
|
606
|
+
message_id = _progress_card_message_id(result)
|
|
607
|
+
else:
|
|
608
|
+
async with self._goal_card_lock:
|
|
609
|
+
if not await self._update_message(
|
|
610
|
+
message_id,
|
|
611
|
+
card,
|
|
612
|
+
binding_id=binding_id,
|
|
613
|
+
operation_id=logical_turn_id,
|
|
614
|
+
):
|
|
615
|
+
return False
|
|
616
|
+
except asyncio.CancelledError:
|
|
617
|
+
raise
|
|
618
|
+
except Exception:
|
|
619
|
+
logger.exception(
|
|
620
|
+
"failed to establish Goal Reply Card",
|
|
621
|
+
extra={
|
|
622
|
+
"binding_id": binding_id,
|
|
623
|
+
"logical_turn_id": logical_turn_id,
|
|
624
|
+
},
|
|
625
|
+
)
|
|
626
|
+
return False
|
|
627
|
+
if message_id is None:
|
|
628
|
+
logger.error(
|
|
629
|
+
"failed to establish Goal Reply Card: reply message ID unavailable",
|
|
630
|
+
extra={
|
|
631
|
+
"binding_id": binding_id,
|
|
632
|
+
"logical_turn_id": logical_turn_id,
|
|
633
|
+
},
|
|
634
|
+
)
|
|
635
|
+
return False
|
|
636
|
+
if self._runtime.lifecycle_state(binding_id) is not None:
|
|
637
|
+
return False
|
|
638
|
+
origin.message_id = message_id
|
|
639
|
+
origin.goal_generation = generation
|
|
640
|
+
self._retired_goal_runs = {
|
|
641
|
+
item
|
|
642
|
+
for item in self._retired_goal_runs
|
|
643
|
+
if item[:2] != (message_id, generation)
|
|
644
|
+
}
|
|
645
|
+
session = _GoalReplyCardSession(
|
|
646
|
+
binding_id=binding_id,
|
|
647
|
+
thread_id=thread_id,
|
|
648
|
+
goal_generation=generation,
|
|
649
|
+
logical_turn_id=logical_turn_id,
|
|
650
|
+
message_id=message_id,
|
|
651
|
+
stopped=asyncio.Event(),
|
|
652
|
+
projection=projection,
|
|
653
|
+
revision=revision,
|
|
654
|
+
refresh=refresh,
|
|
655
|
+
)
|
|
656
|
+
self._goal_sessions[key] = session
|
|
657
|
+
self._remember_goal_projection(message_id, generation, projection)
|
|
658
|
+
if refresh is not None:
|
|
659
|
+
session.task = asyncio.create_task(
|
|
660
|
+
self._poll_goal(session),
|
|
661
|
+
name=f"netizen-goal-reply-card-{logical_turn_id}",
|
|
662
|
+
)
|
|
663
|
+
return True
|
|
664
|
+
|
|
665
|
+
async def finish_goal(
|
|
666
|
+
self,
|
|
667
|
+
*,
|
|
668
|
+
binding_id: str,
|
|
669
|
+
thread_id: str,
|
|
670
|
+
logical_turn_id: str | None,
|
|
671
|
+
generation: str,
|
|
672
|
+
origin: GoalCardOrigin,
|
|
673
|
+
projection: ReplyCardProjection,
|
|
674
|
+
retain_session: bool,
|
|
675
|
+
) -> _GoalCardReceipt:
|
|
676
|
+
async with self._goal_lock:
|
|
677
|
+
return await self._finish_goal_locked(
|
|
678
|
+
binding_id=binding_id,
|
|
679
|
+
thread_id=thread_id,
|
|
680
|
+
logical_turn_id=logical_turn_id,
|
|
681
|
+
generation=generation,
|
|
682
|
+
origin=origin,
|
|
683
|
+
projection=projection,
|
|
684
|
+
retain_session=retain_session,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
async def reply_goal_fallback(
|
|
688
|
+
self,
|
|
689
|
+
*,
|
|
690
|
+
binding_id: str,
|
|
691
|
+
thread_id: str,
|
|
692
|
+
logical_turn_id: str | None,
|
|
693
|
+
generation: str,
|
|
694
|
+
target: object,
|
|
695
|
+
card: OutboundCard,
|
|
696
|
+
origin: GoalCardOrigin,
|
|
697
|
+
projection: ReplyCardProjection,
|
|
698
|
+
retain_session: bool,
|
|
699
|
+
) -> _GoalCardDelivery:
|
|
700
|
+
"""CAS, reply, and adopt one fallback while the Goal route is stable."""
|
|
701
|
+
|
|
702
|
+
async with self._goal_lock:
|
|
703
|
+
if self._closed:
|
|
704
|
+
return _GoalCardDelivery.NOT_ATTEMPTED
|
|
705
|
+
key = (binding_id, thread_id, generation)
|
|
706
|
+
latest_run = self._goal_latest_runs.get(key)
|
|
707
|
+
if latest_run is not None and latest_run != logical_turn_id:
|
|
708
|
+
return _GoalCardDelivery.SUPERSEDED
|
|
709
|
+
current = self._goal_sessions.get(key)
|
|
710
|
+
if current is not None and current.logical_turn_id != logical_turn_id:
|
|
711
|
+
return _GoalCardDelivery.SUPERSEDED
|
|
712
|
+
if current is not None:
|
|
713
|
+
self._goal_sessions.pop(key, None)
|
|
714
|
+
await self._stop_session(current)
|
|
715
|
+
try:
|
|
716
|
+
async with asyncio.timeout(self._operation_timeout_seconds):
|
|
717
|
+
result = await self._channel.reply(target, card)
|
|
718
|
+
except asyncio.CancelledError:
|
|
719
|
+
raise
|
|
720
|
+
except Exception:
|
|
721
|
+
logger.exception(
|
|
722
|
+
"terminal Goal card fallback failed",
|
|
723
|
+
extra={"binding_id": binding_id},
|
|
724
|
+
)
|
|
725
|
+
return _GoalCardDelivery.FAILED
|
|
726
|
+
if getattr(result, "success", True) is False:
|
|
727
|
+
logger.error(
|
|
728
|
+
"terminal Goal card fallback was not confirmed",
|
|
729
|
+
extra={"binding_id": binding_id},
|
|
730
|
+
)
|
|
731
|
+
return _GoalCardDelivery.FAILED
|
|
732
|
+
message_id = _progress_card_message_id(result)
|
|
733
|
+
if message_id is None:
|
|
734
|
+
logger.warning(
|
|
735
|
+
"terminal Goal fallback lacks a reusable message identity",
|
|
736
|
+
extra={"binding_id": binding_id},
|
|
737
|
+
)
|
|
738
|
+
return _GoalCardDelivery.DELIVERED
|
|
739
|
+
origin.message_id = message_id
|
|
740
|
+
origin.goal_generation = generation
|
|
741
|
+
if logical_turn_id is not None:
|
|
742
|
+
self._goal_latest_runs[key] = logical_turn_id
|
|
743
|
+
async with self._goal_card_lock:
|
|
744
|
+
if not retain_session:
|
|
745
|
+
return _GoalCardDelivery.DELIVERED
|
|
746
|
+
self._remember_goal_projection(
|
|
747
|
+
message_id,
|
|
748
|
+
generation,
|
|
749
|
+
projection,
|
|
750
|
+
)
|
|
751
|
+
if retain_session:
|
|
752
|
+
self._goal_sessions[key] = _GoalReplyCardSession(
|
|
753
|
+
binding_id=binding_id,
|
|
754
|
+
thread_id=thread_id,
|
|
755
|
+
goal_generation=generation,
|
|
756
|
+
logical_turn_id=logical_turn_id or generation,
|
|
757
|
+
message_id=message_id,
|
|
758
|
+
stopped=asyncio.Event(),
|
|
759
|
+
projection=projection,
|
|
760
|
+
revision=("terminal-fallback",),
|
|
761
|
+
)
|
|
762
|
+
return _GoalCardDelivery.DELIVERED
|
|
763
|
+
|
|
764
|
+
async def _finish_goal_locked(
|
|
765
|
+
self,
|
|
766
|
+
*,
|
|
767
|
+
binding_id: str,
|
|
768
|
+
thread_id: str,
|
|
769
|
+
logical_turn_id: str | None,
|
|
770
|
+
generation: str,
|
|
771
|
+
origin: GoalCardOrigin,
|
|
772
|
+
projection: ReplyCardProjection,
|
|
773
|
+
retain_session: bool,
|
|
774
|
+
) -> _GoalCardReceipt:
|
|
775
|
+
key = (binding_id, thread_id, generation)
|
|
776
|
+
latest_run = self._goal_latest_runs.get(key)
|
|
777
|
+
if latest_run is not None and latest_run != logical_turn_id:
|
|
778
|
+
# The exact Goal generation has already advanced to a newer
|
|
779
|
+
# logical run, even if that newer run has also reached terminal.
|
|
780
|
+
return _GoalCardReceipt(_GoalCardDelivery.SUPERSEDED)
|
|
781
|
+
session = self._goal_sessions.get(key)
|
|
782
|
+
if (
|
|
783
|
+
session is not None
|
|
784
|
+
and session.logical_turn_id != logical_turn_id
|
|
785
|
+
):
|
|
786
|
+
# A resumed run already owns this Goal generation and card. The
|
|
787
|
+
# previous run's delayed terminal projection must not overwrite it.
|
|
788
|
+
return _GoalCardReceipt(_GoalCardDelivery.SUPERSEDED)
|
|
789
|
+
retired_key = (
|
|
790
|
+
origin.message_id or "",
|
|
791
|
+
generation,
|
|
792
|
+
logical_turn_id or "",
|
|
793
|
+
)
|
|
794
|
+
if session is None and retired_key in self._retired_goal_runs:
|
|
795
|
+
self._retired_goal_runs.discard(retired_key)
|
|
796
|
+
return _GoalCardReceipt(_GoalCardDelivery.SUPERSEDED)
|
|
797
|
+
if session is not None:
|
|
798
|
+
self._goal_sessions.pop(key, None)
|
|
799
|
+
if session is not None:
|
|
800
|
+
await self._stop_session(session)
|
|
801
|
+
message_id = session.message_id
|
|
802
|
+
else:
|
|
803
|
+
message_id = origin.message_id
|
|
804
|
+
if message_id is None:
|
|
805
|
+
return _GoalCardReceipt(_GoalCardDelivery.NOT_ATTEMPTED)
|
|
806
|
+
try:
|
|
807
|
+
card = reply_card(projection)
|
|
808
|
+
except Exception:
|
|
809
|
+
logger.exception(
|
|
810
|
+
"failed to render terminal Goal Reply Card",
|
|
811
|
+
extra={"binding_id": binding_id},
|
|
812
|
+
)
|
|
813
|
+
return _GoalCardReceipt(_GoalCardDelivery.NOT_ATTEMPTED)
|
|
814
|
+
async with self._goal_card_lock:
|
|
815
|
+
attempt = await self._update_terminal_message(
|
|
816
|
+
message_id,
|
|
817
|
+
card,
|
|
818
|
+
binding_id=binding_id,
|
|
819
|
+
operation_id=origin.goal_generation or generation,
|
|
820
|
+
)
|
|
821
|
+
delivered = attempt.updated
|
|
822
|
+
if delivered and retain_session:
|
|
823
|
+
self._remember_goal_projection(
|
|
824
|
+
message_id,
|
|
825
|
+
generation,
|
|
826
|
+
projection,
|
|
827
|
+
)
|
|
828
|
+
elif delivered:
|
|
829
|
+
self._goal_cards.pop((message_id, generation), None)
|
|
830
|
+
if delivered and retain_session:
|
|
831
|
+
self._goal_sessions[key] = _GoalReplyCardSession(
|
|
832
|
+
binding_id=binding_id,
|
|
833
|
+
thread_id=thread_id,
|
|
834
|
+
goal_generation=generation,
|
|
835
|
+
logical_turn_id=(
|
|
836
|
+
session.logical_turn_id
|
|
837
|
+
if session is not None
|
|
838
|
+
else (logical_turn_id or generation)
|
|
839
|
+
),
|
|
840
|
+
message_id=message_id,
|
|
841
|
+
stopped=asyncio.Event(),
|
|
842
|
+
projection=projection,
|
|
843
|
+
revision=("terminal",),
|
|
844
|
+
)
|
|
845
|
+
elif delivered:
|
|
846
|
+
self._retired_goal_runs.discard(retired_key)
|
|
847
|
+
return _GoalCardReceipt(
|
|
848
|
+
_GoalCardDelivery.DELIVERED if delivered else _GoalCardDelivery.FAILED,
|
|
849
|
+
message_id if attempt.confirmed else None,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
async def update_goal(
|
|
853
|
+
self,
|
|
854
|
+
*,
|
|
855
|
+
source_id: str,
|
|
856
|
+
generation: str,
|
|
857
|
+
projection: ReplyCardProjection,
|
|
858
|
+
retain_session: bool = True,
|
|
859
|
+
) -> bool:
|
|
860
|
+
async with self._goal_lock:
|
|
861
|
+
return await self._update_goal_locked(
|
|
862
|
+
source_id=source_id,
|
|
863
|
+
generation=generation,
|
|
864
|
+
projection=projection,
|
|
865
|
+
retain_session=retain_session,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
async def refresh_goal_snapshot(
|
|
869
|
+
self,
|
|
870
|
+
*,
|
|
871
|
+
source_id: str,
|
|
872
|
+
generation: str,
|
|
873
|
+
logical_turn_id: str | None,
|
|
874
|
+
projection: ReplyCardProjection,
|
|
875
|
+
) -> bool:
|
|
876
|
+
"""Merge `/goal` status into the canonical card without regressing it."""
|
|
877
|
+
|
|
878
|
+
async with self._goal_lock:
|
|
879
|
+
matched = next(
|
|
880
|
+
(
|
|
881
|
+
session
|
|
882
|
+
for session in self._goal_sessions.values()
|
|
883
|
+
if session.message_id == source_id
|
|
884
|
+
and session.goal_generation == generation
|
|
885
|
+
),
|
|
886
|
+
None,
|
|
887
|
+
)
|
|
888
|
+
current = self._goal_projection(source_id, generation)
|
|
889
|
+
if matched is None:
|
|
890
|
+
# A terminal update won the race after the read-only native
|
|
891
|
+
# snapshot. Its newer projection already answers the status
|
|
892
|
+
# request and must not be overwritten by the stale read.
|
|
893
|
+
return current is not None
|
|
894
|
+
incoming_goal = projection.goal
|
|
895
|
+
assert incoming_goal is not None
|
|
896
|
+
if matched.refresh is not None and (
|
|
897
|
+
(
|
|
898
|
+
logical_turn_id is not None
|
|
899
|
+
and logical_turn_id != matched.logical_turn_id
|
|
900
|
+
)
|
|
901
|
+
or incoming_goal.status != GoalStatus.ACTIVE.value
|
|
902
|
+
):
|
|
903
|
+
return True
|
|
904
|
+
merged = projection
|
|
905
|
+
if current is not None:
|
|
906
|
+
merged_goal = incoming_goal
|
|
907
|
+
if (
|
|
908
|
+
current.goal is not None
|
|
909
|
+
and current.goal.goal_generation == generation
|
|
910
|
+
and current.goal.status == incoming_goal.status
|
|
911
|
+
):
|
|
912
|
+
merged_goal = replace(
|
|
913
|
+
incoming_goal,
|
|
914
|
+
notice=current.goal.notice,
|
|
915
|
+
notice_is_error=current.goal.notice_is_error,
|
|
916
|
+
)
|
|
917
|
+
merged = replace(
|
|
918
|
+
current,
|
|
919
|
+
scope=projection.scope or current.scope,
|
|
920
|
+
goal=merged_goal,
|
|
921
|
+
activity=(
|
|
922
|
+
projection.activity
|
|
923
|
+
if matched.refresh is not None
|
|
924
|
+
else current.activity
|
|
925
|
+
),
|
|
926
|
+
)
|
|
927
|
+
return await self._update_goal_locked(
|
|
928
|
+
source_id=source_id,
|
|
929
|
+
generation=generation,
|
|
930
|
+
projection=merged,
|
|
931
|
+
retain_session=True,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
async def update_goal_module(
|
|
935
|
+
self,
|
|
936
|
+
*,
|
|
937
|
+
source_id: str,
|
|
938
|
+
generation: str,
|
|
939
|
+
scope: FeishuScope,
|
|
940
|
+
goal: ReplyCardGoalModule,
|
|
941
|
+
retain_session: bool,
|
|
942
|
+
) -> bool:
|
|
943
|
+
"""Atomically replace only Goal while preserving other card modules."""
|
|
944
|
+
|
|
945
|
+
async with self._goal_lock:
|
|
946
|
+
current = self._goal_projection(source_id, generation)
|
|
947
|
+
projection = (
|
|
948
|
+
ReplyCardProjection(scope=scope, goal=goal)
|
|
949
|
+
if current is None
|
|
950
|
+
else replace(current, scope=scope, goal=goal)
|
|
951
|
+
)
|
|
952
|
+
return await self._update_goal_locked(
|
|
953
|
+
source_id=source_id,
|
|
954
|
+
generation=generation,
|
|
955
|
+
projection=projection,
|
|
956
|
+
retain_session=retain_session,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
async def _update_goal_locked(
|
|
960
|
+
self,
|
|
961
|
+
*,
|
|
962
|
+
source_id: str,
|
|
963
|
+
generation: str,
|
|
964
|
+
projection: ReplyCardProjection,
|
|
965
|
+
retain_session: bool,
|
|
966
|
+
) -> bool:
|
|
967
|
+
matched_key: tuple[str, str, str] | None = None
|
|
968
|
+
matched: _GoalReplyCardSession | None = None
|
|
969
|
+
for key, session in self._goal_sessions.items():
|
|
970
|
+
if (
|
|
971
|
+
session.message_id == source_id
|
|
972
|
+
and session.goal_generation == generation
|
|
973
|
+
):
|
|
974
|
+
matched_key = key
|
|
975
|
+
matched = session
|
|
976
|
+
break
|
|
977
|
+
if matched_key is not None and matched is not None:
|
|
978
|
+
self._goal_sessions.pop(matched_key, None)
|
|
979
|
+
await self._stop_session(matched)
|
|
980
|
+
if not retain_session and matched.refresh is not None:
|
|
981
|
+
self._retired_goal_runs.add(
|
|
982
|
+
(source_id, generation, matched.logical_turn_id)
|
|
983
|
+
)
|
|
984
|
+
try:
|
|
985
|
+
card = reply_card(projection)
|
|
986
|
+
except Exception:
|
|
987
|
+
logger.exception("failed to render Goal Reply Card update")
|
|
988
|
+
return False
|
|
989
|
+
async with self._goal_card_lock:
|
|
990
|
+
delivered = await self._update_message(
|
|
991
|
+
source_id,
|
|
992
|
+
card,
|
|
993
|
+
binding_id=(
|
|
994
|
+
matched.binding_id if matched is not None else "unknown"
|
|
995
|
+
),
|
|
996
|
+
operation_id=generation,
|
|
997
|
+
)
|
|
998
|
+
if delivered and retain_session:
|
|
999
|
+
self._remember_goal_projection(
|
|
1000
|
+
source_id,
|
|
1001
|
+
generation,
|
|
1002
|
+
projection,
|
|
1003
|
+
)
|
|
1004
|
+
elif delivered:
|
|
1005
|
+
self._goal_cards.pop((source_id, generation), None)
|
|
1006
|
+
if delivered and retain_session and matched_key is not None and matched is not None:
|
|
1007
|
+
refreshed_session = _GoalReplyCardSession(
|
|
1008
|
+
binding_id=matched.binding_id,
|
|
1009
|
+
thread_id=matched.thread_id,
|
|
1010
|
+
goal_generation=matched.goal_generation,
|
|
1011
|
+
logical_turn_id=matched.logical_turn_id,
|
|
1012
|
+
message_id=source_id,
|
|
1013
|
+
stopped=asyncio.Event(),
|
|
1014
|
+
projection=projection,
|
|
1015
|
+
revision=matched.revision,
|
|
1016
|
+
refresh=matched.refresh,
|
|
1017
|
+
)
|
|
1018
|
+
self._goal_sessions[matched_key] = refreshed_session
|
|
1019
|
+
if refreshed_session.refresh is not None:
|
|
1020
|
+
refreshed_session.task = asyncio.create_task(
|
|
1021
|
+
self._poll_goal(refreshed_session),
|
|
1022
|
+
name=(
|
|
1023
|
+
"netizen-goal-reply-card-"
|
|
1024
|
+
f"{refreshed_session.logical_turn_id}"
|
|
1025
|
+
),
|
|
1026
|
+
)
|
|
1027
|
+
return delivered
|
|
1028
|
+
|
|
1029
|
+
async def abandon_goal(
|
|
1030
|
+
self,
|
|
1031
|
+
*,
|
|
1032
|
+
binding_id: str,
|
|
1033
|
+
thread_id: str,
|
|
1034
|
+
logical_turn_id: str | None,
|
|
1035
|
+
generation: str,
|
|
1036
|
+
) -> None:
|
|
1037
|
+
async with self._goal_lock:
|
|
1038
|
+
key = (binding_id, thread_id, generation)
|
|
1039
|
+
session = self._goal_sessions.get(key)
|
|
1040
|
+
if (
|
|
1041
|
+
session is None
|
|
1042
|
+
or session.logical_turn_id != logical_turn_id
|
|
1043
|
+
):
|
|
1044
|
+
return
|
|
1045
|
+
self._goal_sessions.pop(key, None)
|
|
1046
|
+
await self._stop_session(session)
|
|
1047
|
+
|
|
1048
|
+
def goal_projection(
|
|
1049
|
+
self,
|
|
1050
|
+
*,
|
|
1051
|
+
source_id: str,
|
|
1052
|
+
generation: str,
|
|
1053
|
+
) -> ReplyCardProjection | None:
|
|
1054
|
+
return self._goal_projection(source_id, generation)
|
|
1055
|
+
|
|
1056
|
+
def _goal_projection(
|
|
1057
|
+
self,
|
|
1058
|
+
source_id: str,
|
|
1059
|
+
generation: str,
|
|
1060
|
+
) -> ReplyCardProjection | None:
|
|
1061
|
+
current = self._goal_cards.get((source_id, generation))
|
|
1062
|
+
if current is not None:
|
|
1063
|
+
return current
|
|
1064
|
+
for session in self._goal_sessions.values():
|
|
1065
|
+
if (
|
|
1066
|
+
session.message_id == source_id
|
|
1067
|
+
and session.goal_generation == generation
|
|
1068
|
+
):
|
|
1069
|
+
return session.projection
|
|
1070
|
+
return None
|
|
1071
|
+
|
|
1072
|
+
async def update_goal_page(
|
|
1073
|
+
self,
|
|
1074
|
+
*,
|
|
1075
|
+
source_id: str,
|
|
1076
|
+
binding_id: str,
|
|
1077
|
+
generation: str,
|
|
1078
|
+
page: int,
|
|
1079
|
+
render: Callable[[ReplyCardProjection | None], OutboundCard],
|
|
1080
|
+
) -> bool:
|
|
1081
|
+
"""Serialize one Goal file-page rebuild with every card mutation."""
|
|
1082
|
+
|
|
1083
|
+
async with self._goal_lock:
|
|
1084
|
+
async with self._goal_card_lock:
|
|
1085
|
+
current = self._goal_projection(source_id, generation)
|
|
1086
|
+
if (
|
|
1087
|
+
current is not None
|
|
1088
|
+
and current.goal is not None
|
|
1089
|
+
and current.goal.binding_id != binding_id
|
|
1090
|
+
):
|
|
1091
|
+
raise CardActionError("Goal 文件卡片的会话身份不一致。")
|
|
1092
|
+
card = render(current)
|
|
1093
|
+
delivered = await self._update_message(
|
|
1094
|
+
source_id,
|
|
1095
|
+
card,
|
|
1096
|
+
binding_id=binding_id,
|
|
1097
|
+
operation_id=generation,
|
|
1098
|
+
)
|
|
1099
|
+
if delivered and current is not None and current.files is not None:
|
|
1100
|
+
current = replace(
|
|
1101
|
+
current,
|
|
1102
|
+
files=replace(current.files, page=page),
|
|
1103
|
+
)
|
|
1104
|
+
self._remember_goal_projection(
|
|
1105
|
+
source_id,
|
|
1106
|
+
generation,
|
|
1107
|
+
current,
|
|
1108
|
+
)
|
|
1109
|
+
for session in self._goal_sessions.values():
|
|
1110
|
+
if (
|
|
1111
|
+
session.message_id == source_id
|
|
1112
|
+
and session.goal_generation == generation
|
|
1113
|
+
):
|
|
1114
|
+
session.projection = current
|
|
1115
|
+
break
|
|
1116
|
+
return delivered
|
|
1117
|
+
|
|
1118
|
+
def _remember_goal_projection(
|
|
1119
|
+
self,
|
|
1120
|
+
source_id: str,
|
|
1121
|
+
generation: str,
|
|
1122
|
+
projection: ReplyCardProjection,
|
|
1123
|
+
) -> None:
|
|
1124
|
+
key = (source_id, generation)
|
|
1125
|
+
if key not in self._goal_cards and (
|
|
1126
|
+
len(self._goal_cards) >= _GOAL_REPLY_CARD_CACHE_LIMIT
|
|
1127
|
+
):
|
|
1128
|
+
self._goal_cards.pop(next(iter(self._goal_cards)))
|
|
1129
|
+
self._goal_cards[key] = _retained_goal_projection(projection)
|
|
1130
|
+
|
|
1131
|
+
def goal_message_id(
|
|
1132
|
+
self,
|
|
1133
|
+
*,
|
|
1134
|
+
binding_id: str,
|
|
1135
|
+
thread_id: str,
|
|
1136
|
+
generation: str,
|
|
1137
|
+
) -> str | None:
|
|
1138
|
+
session = self._goal_sessions.get((binding_id, thread_id, generation))
|
|
1139
|
+
return None if session is None else session.message_id
|
|
1140
|
+
|
|
1141
|
+
def owns_goal_card(
|
|
1142
|
+
self,
|
|
1143
|
+
*,
|
|
1144
|
+
source_id: str,
|
|
1145
|
+
binding_id: str,
|
|
1146
|
+
thread_id: str,
|
|
1147
|
+
generation: str,
|
|
1148
|
+
) -> bool:
|
|
1149
|
+
"""Return whether one live exact Goal route owns this control card."""
|
|
1150
|
+
|
|
1151
|
+
session = self._goal_sessions.get((binding_id, thread_id, generation))
|
|
1152
|
+
return session is not None and session.message_id == source_id
|
|
1153
|
+
|
|
1154
|
+
async def close(self) -> None:
|
|
1155
|
+
if self._closed:
|
|
1156
|
+
return
|
|
1157
|
+
self._closed = True
|
|
1158
|
+
sessions = tuple(self._sessions.values())
|
|
1159
|
+
self._sessions.clear()
|
|
1160
|
+
side_sessions = tuple(self._side_sessions.values())
|
|
1161
|
+
self._side_sessions.clear()
|
|
1162
|
+
async with self._goal_lock:
|
|
1163
|
+
goal_sessions = tuple(self._goal_sessions.values())
|
|
1164
|
+
self._goal_sessions.clear()
|
|
1165
|
+
self._retired_goal_runs.clear()
|
|
1166
|
+
self._goal_latest_runs.clear()
|
|
1167
|
+
self._goal_cards.clear()
|
|
1168
|
+
await asyncio.gather(
|
|
1169
|
+
*(
|
|
1170
|
+
self._stop_session(session)
|
|
1171
|
+
for session in (*sessions, *side_sessions, *goal_sessions)
|
|
1172
|
+
),
|
|
1173
|
+
return_exceptions=False,
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
async def _poll(self, session: _TurnProgressCardSession) -> None:
|
|
1177
|
+
failures = 0
|
|
1178
|
+
while not session.stopped.is_set():
|
|
1179
|
+
try:
|
|
1180
|
+
await asyncio.wait_for(
|
|
1181
|
+
session.stopped.wait(),
|
|
1182
|
+
timeout=self._poll_seconds,
|
|
1183
|
+
)
|
|
1184
|
+
return
|
|
1185
|
+
except TimeoutError:
|
|
1186
|
+
pass
|
|
1187
|
+
if session.stopped.is_set():
|
|
1188
|
+
return
|
|
1189
|
+
try:
|
|
1190
|
+
snapshot = self._runtime.turn_activity(
|
|
1191
|
+
session.binding_id,
|
|
1192
|
+
thread_id=session.thread_id,
|
|
1193
|
+
turn_id=session.turn_id,
|
|
1194
|
+
refresh_plan=False,
|
|
1195
|
+
)
|
|
1196
|
+
except Exception:
|
|
1197
|
+
logger.exception(
|
|
1198
|
+
"failed to refresh running progress card",
|
|
1199
|
+
extra={
|
|
1200
|
+
"binding_id": session.binding_id,
|
|
1201
|
+
"turn_id": session.turn_id,
|
|
1202
|
+
},
|
|
1203
|
+
)
|
|
1204
|
+
session.failed = True
|
|
1205
|
+
return
|
|
1206
|
+
if snapshot is None:
|
|
1207
|
+
continue
|
|
1208
|
+
if snapshot.revision == session.snapshot.revision:
|
|
1209
|
+
continue
|
|
1210
|
+
try:
|
|
1211
|
+
card = turn_progress_card(snapshot=snapshot)
|
|
1212
|
+
except Exception:
|
|
1213
|
+
logger.exception(
|
|
1214
|
+
"failed to render running progress card",
|
|
1215
|
+
extra={
|
|
1216
|
+
"binding_id": session.binding_id,
|
|
1217
|
+
"turn_id": session.turn_id,
|
|
1218
|
+
},
|
|
1219
|
+
)
|
|
1220
|
+
session.failed = True
|
|
1221
|
+
return
|
|
1222
|
+
if not await self._update(session, card):
|
|
1223
|
+
failures += 1
|
|
1224
|
+
if failures >= _PROGRESS_CARD_MAX_CONSECUTIVE_FAILURES:
|
|
1225
|
+
session.failed = True
|
|
1226
|
+
return
|
|
1227
|
+
continue
|
|
1228
|
+
session.snapshot = snapshot
|
|
1229
|
+
failures = 0
|
|
1230
|
+
|
|
1231
|
+
async def _poll_side(self, session: _SideTurnProgressCardSession) -> None:
|
|
1232
|
+
failures = 0
|
|
1233
|
+
while not session.stopped.is_set():
|
|
1234
|
+
try:
|
|
1235
|
+
await asyncio.wait_for(
|
|
1236
|
+
session.stopped.wait(),
|
|
1237
|
+
timeout=self._poll_seconds,
|
|
1238
|
+
)
|
|
1239
|
+
return
|
|
1240
|
+
except TimeoutError:
|
|
1241
|
+
pass
|
|
1242
|
+
if session.stopped.is_set():
|
|
1243
|
+
return
|
|
1244
|
+
try:
|
|
1245
|
+
snapshot = self._runtime.side_turn_activity(
|
|
1246
|
+
session.side_id,
|
|
1247
|
+
thread_id=session.thread_id,
|
|
1248
|
+
turn_id=session.turn_id,
|
|
1249
|
+
refresh_plan=False,
|
|
1250
|
+
)
|
|
1251
|
+
except Exception:
|
|
1252
|
+
logger.exception(
|
|
1253
|
+
"failed to refresh running Side progress card",
|
|
1254
|
+
extra={
|
|
1255
|
+
"side_id": session.side_id,
|
|
1256
|
+
"turn_id": session.turn_id,
|
|
1257
|
+
},
|
|
1258
|
+
)
|
|
1259
|
+
session.failed = True
|
|
1260
|
+
return
|
|
1261
|
+
if snapshot is None:
|
|
1262
|
+
continue
|
|
1263
|
+
if snapshot.revision == session.snapshot.revision:
|
|
1264
|
+
continue
|
|
1265
|
+
try:
|
|
1266
|
+
card = turn_progress_card(snapshot=snapshot)
|
|
1267
|
+
except Exception:
|
|
1268
|
+
logger.exception(
|
|
1269
|
+
"failed to render running Side progress card",
|
|
1270
|
+
extra={
|
|
1271
|
+
"side_id": session.side_id,
|
|
1272
|
+
"turn_id": session.turn_id,
|
|
1273
|
+
},
|
|
1274
|
+
)
|
|
1275
|
+
session.failed = True
|
|
1276
|
+
return
|
|
1277
|
+
if not await self._update_side(session, card):
|
|
1278
|
+
failures += 1
|
|
1279
|
+
if failures >= _PROGRESS_CARD_MAX_CONSECUTIVE_FAILURES:
|
|
1280
|
+
session.failed = True
|
|
1281
|
+
return
|
|
1282
|
+
continue
|
|
1283
|
+
session.snapshot = snapshot
|
|
1284
|
+
failures = 0
|
|
1285
|
+
|
|
1286
|
+
async def _poll_goal(self, session: _GoalReplyCardSession) -> None:
|
|
1287
|
+
refresh = session.refresh
|
|
1288
|
+
if refresh is None:
|
|
1289
|
+
return
|
|
1290
|
+
failures = 0
|
|
1291
|
+
while not session.stopped.is_set():
|
|
1292
|
+
try:
|
|
1293
|
+
await asyncio.wait_for(
|
|
1294
|
+
session.stopped.wait(),
|
|
1295
|
+
timeout=self._poll_seconds,
|
|
1296
|
+
)
|
|
1297
|
+
return
|
|
1298
|
+
except TimeoutError:
|
|
1299
|
+
pass
|
|
1300
|
+
if session.stopped.is_set():
|
|
1301
|
+
return
|
|
1302
|
+
try:
|
|
1303
|
+
async with asyncio.timeout(self._operation_timeout_seconds):
|
|
1304
|
+
refreshed = await refresh()
|
|
1305
|
+
except asyncio.CancelledError:
|
|
1306
|
+
raise
|
|
1307
|
+
except Exception:
|
|
1308
|
+
logger.exception(
|
|
1309
|
+
"failed to refresh running Goal Reply Card",
|
|
1310
|
+
extra={
|
|
1311
|
+
"binding_id": session.binding_id,
|
|
1312
|
+
"logical_turn_id": session.logical_turn_id,
|
|
1313
|
+
},
|
|
1314
|
+
)
|
|
1315
|
+
session.failed = True
|
|
1316
|
+
return
|
|
1317
|
+
if refreshed is None:
|
|
1318
|
+
continue
|
|
1319
|
+
revision, projection = refreshed
|
|
1320
|
+
if session.stopped.is_set():
|
|
1321
|
+
return
|
|
1322
|
+
async with self._goal_card_lock:
|
|
1323
|
+
key = (
|
|
1324
|
+
session.binding_id,
|
|
1325
|
+
session.thread_id,
|
|
1326
|
+
session.goal_generation,
|
|
1327
|
+
)
|
|
1328
|
+
if (
|
|
1329
|
+
session.stopped.is_set()
|
|
1330
|
+
or self._goal_sessions.get(key) is not session
|
|
1331
|
+
):
|
|
1332
|
+
return
|
|
1333
|
+
if revision == session.revision:
|
|
1334
|
+
continue
|
|
1335
|
+
try:
|
|
1336
|
+
card = reply_card(projection)
|
|
1337
|
+
except Exception:
|
|
1338
|
+
logger.exception(
|
|
1339
|
+
"failed to render running Goal Reply Card",
|
|
1340
|
+
extra={"binding_id": session.binding_id},
|
|
1341
|
+
)
|
|
1342
|
+
session.failed = True
|
|
1343
|
+
return
|
|
1344
|
+
if not await self._update_message(
|
|
1345
|
+
session.message_id,
|
|
1346
|
+
card,
|
|
1347
|
+
binding_id=session.binding_id,
|
|
1348
|
+
operation_id=session.logical_turn_id,
|
|
1349
|
+
):
|
|
1350
|
+
failures += 1
|
|
1351
|
+
if failures >= _PROGRESS_CARD_MAX_CONSECUTIVE_FAILURES:
|
|
1352
|
+
session.failed = True
|
|
1353
|
+
return
|
|
1354
|
+
continue
|
|
1355
|
+
session.revision = revision
|
|
1356
|
+
session.projection = _retained_goal_projection(projection)
|
|
1357
|
+
failures = 0
|
|
1358
|
+
self._remember_goal_projection(
|
|
1359
|
+
session.message_id,
|
|
1360
|
+
session.goal_generation,
|
|
1361
|
+
projection,
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
async def _update(
|
|
1365
|
+
self,
|
|
1366
|
+
session: _TurnProgressCardSession,
|
|
1367
|
+
card: OutboundCard,
|
|
1368
|
+
) -> bool:
|
|
1369
|
+
return await self._update_message(
|
|
1370
|
+
session.message_id,
|
|
1371
|
+
card,
|
|
1372
|
+
binding_id=session.binding_id,
|
|
1373
|
+
operation_id=session.turn_id,
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
async def _update_side(
|
|
1377
|
+
self,
|
|
1378
|
+
session: _SideTurnProgressCardSession,
|
|
1379
|
+
card: OutboundCard,
|
|
1380
|
+
) -> bool:
|
|
1381
|
+
return await self._update_message(
|
|
1382
|
+
session.message_id,
|
|
1383
|
+
card,
|
|
1384
|
+
binding_id=f"side:{session.side_id}",
|
|
1385
|
+
operation_id=session.turn_id,
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
async def _update_message(
|
|
1389
|
+
self,
|
|
1390
|
+
message_id: str,
|
|
1391
|
+
card: OutboundCard,
|
|
1392
|
+
*,
|
|
1393
|
+
binding_id: str,
|
|
1394
|
+
operation_id: str,
|
|
1395
|
+
) -> bool:
|
|
1396
|
+
attempt = await self._attempt_update_message(
|
|
1397
|
+
message_id, card, binding_id=binding_id, operation_id=operation_id,
|
|
1398
|
+
)
|
|
1399
|
+
return attempt.updated
|
|
1400
|
+
|
|
1401
|
+
async def _update_terminal_message(
|
|
1402
|
+
self,
|
|
1403
|
+
message_id: str,
|
|
1404
|
+
card: OutboundCard,
|
|
1405
|
+
*,
|
|
1406
|
+
binding_id: str,
|
|
1407
|
+
operation_id: str,
|
|
1408
|
+
) -> _CardUpdateAttempt:
|
|
1409
|
+
# Retry only replacement of the same frozen card, never a new reply.
|
|
1410
|
+
# All attempts and delays share the existing terminal update budget.
|
|
1411
|
+
uncertain = False
|
|
1412
|
+
try:
|
|
1413
|
+
async with asyncio.timeout(self._operation_timeout_seconds):
|
|
1414
|
+
for index in range(_TERMINAL_CARD_UPDATE_ATTEMPTS):
|
|
1415
|
+
if index:
|
|
1416
|
+
await asyncio.sleep(_TERMINAL_CARD_RETRY_SECONDS)
|
|
1417
|
+
attempt = await self._attempt_update_message(
|
|
1418
|
+
message_id, card,
|
|
1419
|
+
binding_id=binding_id, operation_id=operation_id,
|
|
1420
|
+
)
|
|
1421
|
+
if attempt.updated:
|
|
1422
|
+
return attempt
|
|
1423
|
+
code = _send_result_error_code(attempt.result)
|
|
1424
|
+
uncertain |= code is None or code <= 0
|
|
1425
|
+
# A later rejection cannot disprove an earlier lost response.
|
|
1426
|
+
return _CardUpdateAttempt(message_id, None) if uncertain else attempt
|
|
1427
|
+
except TimeoutError:
|
|
1428
|
+
logger.warning(
|
|
1429
|
+
"terminal progress-card update budget exhausted",
|
|
1430
|
+
extra={
|
|
1431
|
+
"binding_id": binding_id,
|
|
1432
|
+
"operation_id": operation_id,
|
|
1433
|
+
"message_id": message_id,
|
|
1434
|
+
},
|
|
1435
|
+
)
|
|
1436
|
+
# The last request may have reached Feishu before cancellation.
|
|
1437
|
+
return _CardUpdateAttempt(message_id, None)
|
|
1438
|
+
|
|
1439
|
+
async def _attempt_update_message(
|
|
1440
|
+
self,
|
|
1441
|
+
message_id: str,
|
|
1442
|
+
card: OutboundCard,
|
|
1443
|
+
*,
|
|
1444
|
+
binding_id: str,
|
|
1445
|
+
operation_id: str,
|
|
1446
|
+
) -> _CardUpdateAttempt:
|
|
1447
|
+
try:
|
|
1448
|
+
async with asyncio.timeout(self._operation_timeout_seconds):
|
|
1449
|
+
result = await self._channel.update_card(
|
|
1450
|
+
message_id,
|
|
1451
|
+
card.card,
|
|
1452
|
+
)
|
|
1453
|
+
except asyncio.CancelledError:
|
|
1454
|
+
raise
|
|
1455
|
+
except Exception:
|
|
1456
|
+
logger.exception(
|
|
1457
|
+
"failed to update progress card",
|
|
1458
|
+
extra={
|
|
1459
|
+
"binding_id": binding_id,
|
|
1460
|
+
"operation_id": operation_id,
|
|
1461
|
+
"message_id": message_id,
|
|
1462
|
+
},
|
|
1463
|
+
)
|
|
1464
|
+
return _CardUpdateAttempt(message_id, None)
|
|
1465
|
+
attempt = _CardUpdateAttempt(message_id, result)
|
|
1466
|
+
if not attempt.updated:
|
|
1467
|
+
logger.error(
|
|
1468
|
+
"failed to update progress card: unsuccessful result",
|
|
1469
|
+
extra={
|
|
1470
|
+
"binding_id": binding_id,
|
|
1471
|
+
"operation_id": operation_id,
|
|
1472
|
+
"message_id": message_id,
|
|
1473
|
+
},
|
|
1474
|
+
)
|
|
1475
|
+
return attempt
|
|
1476
|
+
|
|
1477
|
+
@staticmethod
|
|
1478
|
+
async def _stop_session(
|
|
1479
|
+
session: (
|
|
1480
|
+
_TurnProgressCardSession
|
|
1481
|
+
| _SideTurnProgressCardSession
|
|
1482
|
+
| _GoalReplyCardSession
|
|
1483
|
+
),
|
|
1484
|
+
) -> None:
|
|
1485
|
+
session.stopped.set()
|
|
1486
|
+
task = session.task
|
|
1487
|
+
if task is None or task is asyncio.current_task():
|
|
1488
|
+
return
|
|
1489
|
+
try:
|
|
1490
|
+
await task
|
|
1491
|
+
except asyncio.CancelledError:
|
|
1492
|
+
raise
|
|
1493
|
+
except Exception:
|
|
1494
|
+
logger.exception(
|
|
1495
|
+
"progress card updater failed while stopping",
|
|
1496
|
+
extra={
|
|
1497
|
+
"binding_id": getattr(session, "binding_id", None),
|
|
1498
|
+
"side_id": getattr(session, "side_id", None),
|
|
1499
|
+
"operation_id": getattr(
|
|
1500
|
+
session,
|
|
1501
|
+
"turn_id",
|
|
1502
|
+
getattr(session, "logical_turn_id", "unknown"),
|
|
1503
|
+
),
|
|
1504
|
+
},
|
|
1505
|
+
)
|