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,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
+ )