ergane-cli 0.1.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 (121) hide show
  1. ergane_cli-0.1.0.dist-info/METADATA +148 -0
  2. ergane_cli-0.1.0.dist-info/RECORD +121 -0
  3. ergane_cli-0.1.0.dist-info/WHEEL +4 -0
  4. ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  6. ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
  7. factory/__init__.py +0 -0
  8. factory/activities/__init__.py +0 -0
  9. factory/activities/agent_activities.py +745 -0
  10. factory/activities/merge_activities.py +654 -0
  11. factory/activities/notify_activities.py +1078 -0
  12. factory/activities/roadmap_activities.py +577 -0
  13. factory/activities/usage_activities.py +593 -0
  14. factory/activities/verify_activities.py +617 -0
  15. factory/cli/__init__.py +0 -0
  16. factory/cli/completion.py +150 -0
  17. factory/cli/doctor.py +484 -0
  18. factory/cli/env.py +247 -0
  19. factory/cli/errors.py +77 -0
  20. factory/cli/init.py +1057 -0
  21. factory/cli/install.py +679 -0
  22. factory/cli/main.py +212 -0
  23. factory/cli/nouns/__init__.py +59 -0
  24. factory/cli/nouns/answer.py +134 -0
  25. factory/cli/nouns/build.py +1222 -0
  26. factory/cli/nouns/completion.py +13 -0
  27. factory/cli/nouns/doctor.py +13 -0
  28. factory/cli/nouns/env.py +13 -0
  29. factory/cli/nouns/escalations.py +93 -0
  30. factory/cli/nouns/findings.py +13 -0
  31. factory/cli/nouns/init.py +13 -0
  32. factory/cli/nouns/install.py +99 -0
  33. factory/cli/nouns/repo.py +13 -0
  34. factory/cli/nouns/roadmap.py +13 -0
  35. factory/cli/nouns/spec.py +511 -0
  36. factory/cli/nouns/status.py +18 -0
  37. factory/cli/nouns/usage.py +13 -0
  38. factory/cli/nouns/worker.py +69 -0
  39. factory/cli/repo.py +569 -0
  40. factory/cli/repo_export.py +282 -0
  41. factory/cli/roadmap.py +434 -0
  42. factory/cli/status.py +844 -0
  43. factory/cli/usage.py +129 -0
  44. factory/config.py +262 -0
  45. factory/controlplane/__init__.py +0 -0
  46. factory/controlplane/config.py +863 -0
  47. factory/controlplane/resolve.py +434 -0
  48. factory/controlplane/verify.py +827 -0
  49. factory/discovery/__init__.py +3 -0
  50. factory/discovery/llm_scanner.py +176 -0
  51. factory/doctor/__init__.py +0 -0
  52. factory/doctor/cli.py +392 -0
  53. factory/doctor/models.py +165 -0
  54. factory/doctor/probes.py +522 -0
  55. factory/doctor/scaffold.py +184 -0
  56. factory/doctor/store.py +449 -0
  57. factory/env.py +131 -0
  58. factory/escalation/__init__.py +0 -0
  59. factory/escalation/client.py +168 -0
  60. factory/escalation/question.py +161 -0
  61. factory/escalation/workflow.py +470 -0
  62. factory/locking.py +85 -0
  63. factory/mergequeue/__init__.py +21 -0
  64. factory/mergequeue/classify.py +133 -0
  65. factory/mergequeue/forge.py +401 -0
  66. factory/mergequeue/gh.py +484 -0
  67. factory/mergequeue/github_forge.py +349 -0
  68. factory/mergequeue/merge_queue_ruleset.json +32 -0
  69. factory/mergequeue/messages.py +141 -0
  70. factory/mergequeue/models.py +324 -0
  71. factory/mergequeue/onboard.py +537 -0
  72. factory/mergequeue/wiring.py +466 -0
  73. factory/notify/__init__.py +0 -0
  74. factory/notify/adapter.py +263 -0
  75. factory/notify/messages.py +474 -0
  76. factory/notify/service.py +809 -0
  77. factory/notify/webhook.py +151 -0
  78. factory/personas.yaml +161 -0
  79. factory/registry.py +583 -0
  80. factory/roadmap/__init__.py +8 -0
  81. factory/roadmap/cli.py +125 -0
  82. factory/roadmap/discovery.py +286 -0
  83. factory/roadmap/models.py +583 -0
  84. factory/roadmap/schedule.py +377 -0
  85. factory/roadmap/workflow.py +1364 -0
  86. factory/supervision/__init__.py +0 -0
  87. factory/supervision/alert.py +239 -0
  88. factory/supervision/probe.py +467 -0
  89. factory/supervision/temporal_server.py +121 -0
  90. factory/supervision/units.py +649 -0
  91. factory/usage/__init__.py +0 -0
  92. factory/usage/aggregate.py +113 -0
  93. factory/usage/cli.py +223 -0
  94. factory/usage/ledger.py +295 -0
  95. factory/usage/litellm_client.py +486 -0
  96. factory/usage/models.py +149 -0
  97. factory/verify/__init__.py +0 -0
  98. factory/verify/criteria.py +488 -0
  99. factory/verify/diffbounds.py +159 -0
  100. factory/verify/diffcheck.py +506 -0
  101. factory/verify/factory_yaml.py +842 -0
  102. factory/verify/gates.py +1341 -0
  103. factory/verify/judge.py +775 -0
  104. factory/verify/ladder.py +145 -0
  105. factory/verify/models.py +745 -0
  106. factory/verify/question.py +167 -0
  107. factory/verify/store.py +1366 -0
  108. factory/verify/toolchain.py +370 -0
  109. factory/worker.py +296 -0
  110. factory/workgraph/__init__.py +0 -0
  111. factory/workgraph/adapter.py +1333 -0
  112. factory/workgraph/cli.py +801 -0
  113. factory/workgraph/delta.py +278 -0
  114. factory/workgraph/derive.py +607 -0
  115. factory/workgraph/detector.py +509 -0
  116. factory/workgraph/landed.py +496 -0
  117. factory/workgraph/models.py +494 -0
  118. factory/workgraph/preflight.py +651 -0
  119. factory/workgraph/prompt.py +796 -0
  120. factory/workgraph/workflow.py +2733 -0
  121. factory/workgraph/worktree.py +1322 -0
@@ -0,0 +1,1078 @@
1
+ """The two calls that page a human, and what they promise when nobody answers.
2
+
3
+ `factory/notify/` renders an escalation and turns a button press back into a
4
+ signal; this module is the other half — what the workflow calls when the ladder
5
+ has run out of ideas (`send_escalation`) and when the hour has run out
6
+ (`expire_escalation`). Between them they are the only place the bot token is
7
+ read, and it is read from the worker environment inside the activity, never from
8
+ an input (FR-009, the discipline 001 established for the master key).
9
+
10
+ Three decisions carry the weight:
11
+
12
+ - **The row is written before the message goes out (R11).** Every other ordering
13
+ can lose an escalation: a message that exists before its row is a button
14
+ pointing at nothing — answered by a bridge that has never heard of it, waited
15
+ on by a workflow nothing will ever signal, and invisible to the timeout path
16
+ that would otherwise expire it. Insert first and the worst case is a recorded
17
+ escalation nobody was told about, which is exactly what `delivered=False`
18
+ reports.
19
+ - **A notifier that is down is data, not an error.** No token, no chat id, a
20
+ refused connection, an API that says no: all of them return
21
+ `delivered=False` with the row retained, because the workflow's response is to
22
+ apply the fail-safe default (KILL) immediately rather than wait out an hour for
23
+ a message nobody received. Raising would instead hand the escalation to
24
+ Temporal's retry policy and stall the node on the notifier's availability — the
25
+ one dependency the send path was designed not to have. A store it cannot write
26
+ is the one real error: `delivered=False` is survivable because the row is still
27
+ expirable, and a missing row is not recoverable by anything downstream.
28
+ - **Expiry reports a transition rather than asserting one.** The operator's
29
+ button press and the workflow's hour race by design (R12), and the timeout path
30
+ cannot know it lost until it asks. `expire_escalation` returns whatever the
31
+ store's guarded UPDATE settled on — `EXPIRED` when it was still pending, the
32
+ operator's choice when a press got there first, `None` for an id the store has
33
+ never heard of — and never raises on the ordinary cases, because a workflow
34
+ blocked on this call is a workflow that cannot apply its own default.
35
+
36
+ `open_bot` is a seam in the same sense as component 1's `open_client` and the
37
+ judge's `judge_transport`: tests replace it to keep a socket from opening, and
38
+ the token still has to come out of the environment for the activity to get that
39
+ far. Since 041-US1 it is re-exported from `factory/notify/service.py`, where the
40
+ Telegram transport now lives, and handed to whichever adapter this worker is
41
+ configured with — patching it here still reaches every send, because the name is
42
+ looked up in this module at call time.
43
+
44
+ What changed in 041-US1 and what did not: these activities no longer know they
45
+ are talking to Telegram. They render a message (`factory/notify/messages.py`),
46
+ hand it to the configured `MessengerAdapter` with the escalation's or question's
47
+ own id as the correlation id, and read a `DeliveryReceipt`. The row-first
48
+ ordering, the fail-safe reading of a failed send, and the raise on a store that
49
+ will not take the row are all unchanged — they were never transport concerns.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import logging
55
+ import secrets
56
+ import sqlite3
57
+ from contextlib import closing
58
+ from dataclasses import dataclass, field
59
+ from datetime import datetime, timedelta, timezone
60
+ from enum import Enum
61
+ from pathlib import Path
62
+ from typing import Any
63
+
64
+ from temporalio import activity
65
+ from temporalio.exceptions import ApplicationError
66
+
67
+ from factory.activities.verify_activities import (
68
+ DEFAULT_VERIFICATION_DB_PATH,
69
+ ERGANE_VERIFICATION_DB_PATH_ENV,
70
+ FACTORY_VERIFICATION_DB_PATH_ENV,
71
+ VERIFICATION_DB_PATH_ENV,
72
+ )
73
+ from factory.notify.adapter import RenderedMessage, resolve_adapter
74
+ from factory.notify.messages import (
75
+ escalation_actions,
76
+ escalation_message,
77
+ question_message,
78
+ roadmap_failure_notice,
79
+ roadmap_recovery_notice,
80
+ )
81
+ from factory.notify.service import open_bot
82
+ from factory.mergequeue.models import CheckFailure
83
+ from factory.verify import store
84
+ from factory.verify.models import EscalationChoice, EscalationRecord, QuestionRecord
85
+
86
+ logger = logging.getLogger(__name__)
87
+
88
+ #: Read out of the worker environment by the Telegram transport
89
+ #: (`factory.notify.service.BOT_TOKEN_ENV`, the same variable under the module
90
+ #: that now owns the send) and never placed in an input, a result, a row or a
91
+ #: log line. Named here because these activities are where a caller configuring
92
+ #: a worker looks for it, and because the credential sweep asserts that this
93
+ #: pair is spelled in exactly the two modules entitled to the value.
94
+ TELEGRAM_BOT_TOKEN_ENV = "TELEGRAM_BOT_TOKEN"
95
+
96
+ #: Where escalations are sent. Not a credential, but worker configuration all the
97
+ #: same: an escalation addressed by the dispatch could be redirected by anything
98
+ #: that could write one.
99
+ TELEGRAM_CHAT_ID_ENV = "TELEGRAM_CHAT_ID"
100
+
101
+ #: The activity error type for an escalation that could not be recorded. Unlike a
102
+ #: failed send, this one raises: an unwritten row cannot be expired, resolved or
103
+ #: found again, so nothing downstream could recover from it.
104
+ ESCALATION_NOT_RECORDED = "ESCALATION_NOT_RECORDED"
105
+
106
+ #: How long an operator has before silence becomes a kill (data-model.md,
107
+ #: `VerificationConfig.escalation_timeout_s`). The row advertises the deadline the
108
+ #: workflow's own timer is holding, so both answer the same question the same way.
109
+ ESCALATION_TIMEOUT_S = 3600
110
+
111
+ #: What the ladder offers when the dispatch does not narrow it (FR-008).
112
+ DEFAULT_CHOICES = (
113
+ EscalationChoice.RETRY,
114
+ EscalationChoice.KILL,
115
+ EscalationChoice.PAUSE_EPIC,
116
+ )
117
+
118
+ #: The activity error type for a question that could not be recorded (008-US1).
119
+ #: The mirror of `ESCALATION_NOT_RECORDED`: an unwritten row cannot be expired,
120
+ #: resolved or found again, so it raises before a message exists in a chat.
121
+ QUESTION_NOT_RECORDED = "QUESTION_NOT_RECORDED"
122
+
123
+ #: How long an operator has to answer a question before silence reclassifies it
124
+ #: as a burn (FR-004). The question's own window, not the escalation hour: a
125
+ #: question asked into an operator's sleep is cheaper parked till morning than
126
+ #: burned at 3 AM (decided 2026-08-07). The row advertises the deadline the
127
+ #: workflow's timer (US2) holds.
128
+ QUESTION_TIMEOUT_S = 28800
129
+
130
+
131
+ def _deliver(message: RenderedMessage, correlation_id: str) -> Any:
132
+ """Hand one rendered message to whichever transport this worker pages over.
133
+
134
+ Resolved per call, not per process: `open_bot` is looked up in this module's
135
+ namespace at the moment of the send, which is what keeps the seam the 008
136
+ suite patches — `monkeypatch.setattr(notify_activities, "open_bot", …)` —
137
+ reaching the adapter that actually opens the socket.
138
+ """
139
+ return resolve_adapter(open_bot=open_bot).deliver(message, correlation_id)
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class SendEscalationInput:
144
+ """One escalation, in the terms an operator will read it in.
145
+
146
+ There is deliberately no field for a token or a chat id: a credential in an
147
+ activity input is a credential in the workflow's history forever (FR-009).
148
+ `history_summary` is the full failure history the ladder assembled (SC-005) —
149
+ the store keeps it whole and only the message is ever clipped.
150
+
151
+ `escalation_id` is optional: when a caller has already recorded the row
152
+ (US4's roadmap failure path writes it before attempting delivery so a down
153
+ notifier loses the message but not the fact), the same id is reused and the
154
+ insert is skipped. When omitted, `send_escalation` mints a fresh id and
155
+ inserts the row as usual (R11).
156
+ """
157
+
158
+ workflow_id: str
159
+ epic_id: str
160
+ node_id: str
161
+ history_summary: str
162
+ choices: list[EscalationChoice] = field(default_factory=lambda: list(DEFAULT_CHOICES))
163
+ timeout_s: int = ESCALATION_TIMEOUT_S
164
+ escalation_id: str | None = None
165
+ #: US2: the failing check evidence to render into the operator-facing message.
166
+ check_evidence: tuple[CheckFailure, ...] = ()
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class SentEscalation:
171
+ """What the workflow needs to decide how to wait.
172
+
173
+ `delivered=False` means nobody was paged, and the caller applies the fail-safe
174
+ default without waiting (R11). `expires_at` is the row's deadline, so the
175
+ workflow's timer and the evidence agree on when the hour is up.
176
+ """
177
+
178
+ escalation_id: str
179
+ delivered: bool
180
+ expires_at: str
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class ExpireEscalationInput:
185
+ escalation_id: str
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class SettleEscalationInput:
190
+ """One operator decision, on its way to the row (041-US2, FR-013).
191
+
192
+ `choice` is a value from the closed enum the escalations table's CHECK
193
+ constraints admit. Validating that it was *offered* is the workflow's, in
194
+ workflow scope where it is pure; by the time it reaches here it has been
195
+ checked, which is what keeps this activity from being the thing that fails a
196
+ workflow over a forged button.
197
+ """
198
+
199
+ escalation_id: str
200
+ choice: str
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class SettledEscalation:
205
+ """What the escalation settled on, and whether this call is what settled it.
206
+
207
+ `final_state` is the row's terminal resolution — the operator's choice when
208
+ this call won, whatever got there first when it lost, and `None` for an id
209
+ the store has never heard of. `settled_here` is False when the guarded
210
+ UPDATE matched nothing, which is how the caller learns it lost rather than
211
+ by comparing timestamps.
212
+ """
213
+
214
+ final_state: str | None
215
+ settled_here: bool
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class ExpiredEscalation:
220
+ """What the escalation settled on — or `None` when there is nothing to report.
221
+
222
+ `None` covers an id the store has never heard of (a store rebuilt under a
223
+ running epic, a row lost with the disk). The workflow applies its default
224
+ either way; there is simply no recorded state to hand back.
225
+ """
226
+
227
+ final_state: str | None
228
+
229
+
230
+ # --- operator questions (008-US1) -------------------------------------------
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class SendQuestionInput:
235
+ """One question, in the terms an operator will read it in (FR-002).
236
+
237
+ The mirror of `SendEscalationInput` with the deltas that make a question a
238
+ question: there is no `choices` field (the operator types a reply rather than
239
+ pressing a button), and `attempt` travels because a question is attributed to
240
+ one attempt the way a teardown's ledger row is. There is deliberately no
241
+ field for a token or a chat id: a credential in an activity input is a
242
+ credential in the workflow's history forever (FR-007, the discipline 001
243
+ established for the master key). `question_text` is the marker body the
244
+ detector extracted, shipped verbatim.
245
+ """
246
+
247
+ workflow_id: str
248
+ epic_id: str
249
+ node_id: str
250
+ attempt: int
251
+ question_text: str
252
+ timeout_s: int = QUESTION_TIMEOUT_S
253
+ #: 041-US3: the id the caller already holds, mirroring the seam
254
+ #: `SendEscalationInput.escalation_id` carries — a `QuestionWorkflow`'s own
255
+ #: id, or a ferried question's, which is an adoption and not a second send.
256
+ question_id: str | None = None
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class SentQuestion:
261
+ """What the workflow needs to know the question is on its way (FR-002).
262
+
263
+ `message_id` is the Telegram message id the bot returned — the reply-routing
264
+ key a free-text answer threads back to (FR-008, US2) — and is ``None`` when no
265
+ message was sent (the notifier is down, unconfigured, or refused the send),
266
+ the way `delivered=False` works for an escalation. `question_id` keys the row
267
+ either way: the row is written before the send (R11), so a crash in between
268
+ leaves something the expiry path (US2) can still close.
269
+ """
270
+
271
+ question_id: str
272
+ message_id: int | None
273
+ sent_at: str
274
+ expires_at: str
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class FindFerriedQuestionInput:
279
+ """The attempt a ferried question would be attributed to (008-US3).
280
+
281
+ The workflow asks the store — not the adapter result — whether a question
282
+ for this attempt already exists before it re-sends on the US1 degrade path.
283
+ D-018's hole stays at one signal (the marker): the ferry's question id is
284
+ evidence in the store, never a second field on the adapter result.
285
+ """
286
+
287
+ epic_id: str
288
+ node_id: str
289
+ attempt: int
290
+
291
+
292
+ @dataclass(frozen=True)
293
+ class FindFerriedQuestion:
294
+ """What the store said about a prior ferry for this attempt (008-US3).
295
+
296
+ `question_id` is the id of the unanswered row the ferry wrote, or ``None``
297
+ when no ferry shipped for this attempt (the US1 path sends fresh, as it did
298
+ before). Only the id is carried back: the message id, timestamps, and text
299
+ are the row's, not the workflow's, and the workflow reuses the row rather
300
+ than re-paging the operator about it.
301
+ """
302
+
303
+ question_id: str | None
304
+
305
+
306
+ @dataclass(frozen=True)
307
+ class SettleQuestionInput:
308
+ """One operator answer, on its way to the row (041-US3, FR-013).
309
+
310
+ The mirror of `SettleEscalationInput`; `answer_text` is free text rather
311
+ than a closed-enum value, which is why `questions` is a sibling table rather
312
+ than a column (plan trap 5).
313
+ """
314
+
315
+ question_id: str
316
+ answer_text: str
317
+
318
+
319
+ @dataclass(frozen=True)
320
+ class SettledQuestion:
321
+ """What the question settled on, and whether this call is what settled it."""
322
+
323
+ final_state: str | None
324
+ settled_here: bool
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class ExpireQuestionInput:
329
+ question_id: str
330
+
331
+
332
+ @dataclass(frozen=True)
333
+ class ExpiredQuestion:
334
+ """What the question settled on — `ANSWERED`/`EXPIRED`, or `None` for an
335
+ unknown id (the escalation precedent: the workflow applies its default either
336
+ way, and there is no recorded state to hand back). US2 owns the call.
337
+ """
338
+
339
+ final_state: str | None
340
+
341
+
342
+ @activity.defn
343
+ async def send_escalation(request: SendEscalationInput) -> SentEscalation:
344
+ """Record an escalation, then page the operator about it (R11).
345
+
346
+ The row is inserted first and committed before the message is built, so a
347
+ crash — or a notifier that is simply down — leaves something the timeout path
348
+ can still expire. Delivery failures are reported, not raised: `delivered=False`
349
+ is the signal that the workflow should stop waiting and apply the default.
350
+
351
+ Raises `ESCALATION_NOT_RECORDED` when the store refuses the row, before any
352
+ message exists. Retryable: nothing was written, so a retry mints a fresh id
353
+ rather than duplicating an escalation.
354
+
355
+ When `request.escalation_id` is set, the caller has already recorded the row
356
+ (US4 writes it before attempting delivery), so `send_escalation` skips the
357
+ insert and only sends + marks delivered. A missing row is treated as a
358
+ caller-side race and inserted defensively.
359
+ """
360
+ record = _pending_record(request)
361
+
362
+ with closing(_connect()) as conn:
363
+ if request.escalation_id is None:
364
+ _insert(conn, record)
365
+ else:
366
+ # US4: the row was written before this send was attempted. If a retry
367
+ # or a store rebuild lost it, fall back to inserting defensively.
368
+ existing = store.get_escalation(conn, record.escalation_id)
369
+ if existing is None:
370
+ _insert(conn, record)
371
+
372
+ delivered = await _send(record)
373
+ if delivered:
374
+ # Best effort, and after the fact by construction: the message is
375
+ # already out, and a row that understates delivery costs a reader one
376
+ # misleading column, where raising here would re-send the message on
377
+ # the next attempt.
378
+ _mark_delivered(conn, record.escalation_id)
379
+
380
+ return SentEscalation(
381
+ escalation_id=record.escalation_id,
382
+ delivered=delivered,
383
+ expires_at=record.expires_at,
384
+ )
385
+
386
+
387
+ @activity.defn
388
+ async def expire_escalation(request: ExpireEscalationInput) -> ExpiredEscalation:
389
+ """Close out the hour, and report what the escalation actually settled on.
390
+
391
+ Marks the row `EXPIRED` iff it is still pending; a press that won the race
392
+ (R12) keeps its resolution and is handed back instead, so the workflow learns
393
+ the operator's answer rather than killing a node they asked to retry. Safe to
394
+ run twice — the store's guarded UPDATE allows exactly one terminal
395
+ transition, so a redelivered activity re-reads rather than re-stamps.
396
+
397
+ Never raises on an unknown id, or on a store it cannot read: the caller's
398
+ fail-safe kill must not be blocked by the same failure that lost the row.
399
+ """
400
+ try:
401
+ with closing(store.connect(_store_path())) as conn:
402
+ if store.expire_escalation(
403
+ conn, request.escalation_id, resolved_at=_now_iso()
404
+ ):
405
+ return ExpiredEscalation(final_state=store.EXPIRED)
406
+
407
+ # The guard matched nothing: either an operator already answered, or
408
+ # there is no such row. Only the row itself can say which.
409
+ record = store.get_escalation(conn, request.escalation_id)
410
+ except (sqlite3.Error, OSError):
411
+ logger.warning(
412
+ "escalation %s: store unreadable; reporting no recorded resolution",
413
+ request.escalation_id,
414
+ )
415
+ return ExpiredEscalation(final_state=None)
416
+
417
+ if record is None or record.resolution is None:
418
+ return ExpiredEscalation(final_state=None)
419
+ return ExpiredEscalation(final_state=_value(record.resolution))
420
+
421
+
422
+ @activity.defn
423
+ async def settle_escalation(request: SettleEscalationInput) -> SettledEscalation:
424
+ """Record the operator's decision on the row — the workflow's own transition.
425
+
426
+ The mirror of `expire_escalation` for the other terminal direction, and it
427
+ exists for one reason: until 041-US2 the *channel* decided whether a row
428
+ settled. A Telegram press went through `CallbackBridge`, which writes the
429
+ row; `ergane build resolve` signalled the workflow and walked away, writing
430
+ nothing. So a resolution meant a settled row or an abandoned one depending
431
+ on where the operator happened to be sitting
432
+ (`interpreter/resolved-escalation-never-clears-in-the-store`, recurred).
433
+ With the lifecycle in a workflow, settlement is the workflow's transition
434
+ and every channel becomes equal because no channel writes.
435
+
436
+ No arbitration is added beside the store's guarded UPDATE (plan trap 2): this
437
+ *is* that UPDATE. When it matches nothing — an operator's press already got
438
+ there, or the hour already expired — the row is read back and its decision
439
+ is what comes home, so a second writer can never overwrite a first.
440
+
441
+ Never raises on an unknown id or an unreadable store, for the reason
442
+ `expire_escalation` does not: the caller's terminal transition must not be
443
+ blocked by the same failure that lost the row.
444
+ """
445
+ try:
446
+ with closing(store.connect(_store_path())) as conn:
447
+ if store.resolve_escalation(
448
+ conn, request.escalation_id, request.choice, resolved_at=_now_iso()
449
+ ):
450
+ return SettledEscalation(
451
+ final_state=request.choice, settled_here=True
452
+ )
453
+
454
+ # The guard matched nothing: something already settled this. Only
455
+ # the row itself can say what.
456
+ record = store.get_escalation(conn, request.escalation_id)
457
+ except (sqlite3.Error, OSError):
458
+ logger.warning(
459
+ "escalation %s: store unreadable; the decision was not recorded",
460
+ request.escalation_id,
461
+ )
462
+ return SettledEscalation(final_state=None, settled_here=False)
463
+
464
+ if record is None or record.resolution is None:
465
+ return SettledEscalation(final_state=None, settled_here=False)
466
+ return SettledEscalation(
467
+ final_state=_value(record.resolution), settled_here=False
468
+ )
469
+
470
+
471
+ # --- the row ----------------------------------------------------------------
472
+
473
+
474
+ def _pending_record(request: SendEscalationInput) -> EscalationRecord:
475
+ """The escalation as it is written down: a fresh id and one hour of patience.
476
+
477
+ Both timestamps come off one instant so the deadline the row advertises is
478
+ exactly `timeout_s` after the moment it was sent — the workflow's timer runs
479
+ against the same span, and a row that disagreed would have the bridge and the
480
+ workflow answering differently about whether a press was still in time.
481
+
482
+ If the caller supplied an `escalation_id`, it is reused (US4 writes the row
483
+ before the send); otherwise a fresh token is minted.
484
+ """
485
+ sent = datetime.now(timezone.utc).replace(microsecond=0)
486
+
487
+ return EscalationRecord(
488
+ # 12 hex digits: the whole reason `callback_data` fits in 64 bytes
489
+ # without ever carrying a workflow id (R11).
490
+ escalation_id=request.escalation_id or secrets.token_hex(6),
491
+ workflow_id=request.workflow_id,
492
+ epic_id=request.epic_id,
493
+ node_id=request.node_id,
494
+ choices=[EscalationChoice(choice) for choice in request.choices],
495
+ history_summary=request.history_summary,
496
+ sent_at=_iso(sent),
497
+ expires_at=_iso(sent + timedelta(seconds=request.timeout_s)),
498
+ delivered=False,
499
+ check_evidence=request.check_evidence,
500
+ )
501
+
502
+
503
+ def _connect() -> sqlite3.Connection:
504
+ """Open the evidence store, or fail before an untracked message exists."""
505
+ try:
506
+ return store.connect(_store_path())
507
+ except (sqlite3.Error, OSError) as exc:
508
+ raise ApplicationError(
509
+ f"cannot open the verification store at {_store_path()}: {exc}",
510
+ type=ESCALATION_NOT_RECORDED,
511
+ ) from exc
512
+
513
+
514
+ def _insert(conn: sqlite3.Connection, record: EscalationRecord) -> None:
515
+ """Write the pending row — the first half of the ordering R11 turns on."""
516
+ try:
517
+ store.insert_escalation(conn, record)
518
+ except sqlite3.Error as exc:
519
+ raise ApplicationError(
520
+ f"could not record the escalation for node {record.node_id!r} "
521
+ f"in epic {record.epic_id!r}: {exc}",
522
+ type=ESCALATION_NOT_RECORDED,
523
+ ) from exc
524
+
525
+
526
+ def _mark_delivered(conn: sqlite3.Connection, escalation_id: str) -> None:
527
+ try:
528
+ store.mark_delivered(conn, escalation_id)
529
+ except sqlite3.Error:
530
+ logger.warning(
531
+ "escalation %s: sent, but the store could not be updated to say so",
532
+ escalation_id,
533
+ )
534
+
535
+
536
+ # --- the message ------------------------------------------------------------
537
+
538
+
539
+ async def _send(record: EscalationRecord) -> bool:
540
+ """Page the operator. False means they were not paged, for any reason at all.
541
+
542
+ The reasons are deliberately not distinguished in the return value: an absent
543
+ token, an unreachable API and a refused message all leave the workflow with
544
+ the same move — apply the default now rather than wait out an hour of silence
545
+ that means nothing. The transport says *why* against the correlation id; this
546
+ line says which escalation it was, because both ids are 12 hex digits and a
547
+ log that named neither kind would be unreadable a week later.
548
+ """
549
+ receipt = await _deliver(
550
+ RenderedMessage(
551
+ text=escalation_message(record),
552
+ actions=escalation_actions(record),
553
+ ),
554
+ record.escalation_id,
555
+ )
556
+ if not receipt.delivered:
557
+ logger.warning("escalation %s: not delivered", record.escalation_id)
558
+ return receipt.delivered
559
+
560
+
561
+ # --- roadmap failure count (US4) --------------------------------------------
562
+
563
+ #: Table that holds the roadmap consecutive-failure count. It lives alongside
564
+ #: the verification evidence store because the count is evidence about a
565
+ #: workflow, not workflow state (which would be lost when the workflow dies).
566
+ _ROADMAP_FAILURES_DDL = """
567
+ CREATE TABLE IF NOT EXISTS roadmap_failures (
568
+ roadmap_id TEXT PRIMARY KEY,
569
+ consecutive_count INTEGER NOT NULL DEFAULT 0 CHECK (consecutive_count >= 0),
570
+ last_failure_text TEXT NOT NULL DEFAULT '',
571
+ updated_at TEXT NOT NULL
572
+ );
573
+ """
574
+
575
+
576
+ @dataclass(frozen=True)
577
+ class RecordRoadmapFailureInput:
578
+ """The facts needed to record one roadmap run failure.
579
+
580
+ `db_path` is worker-local configuration; when omitted, the activity resolves it
581
+ from the worker environment in activity context (FR-002). The same pattern
582
+ `verify_activities` uses for the verification db. No credential is in any
583
+ field.
584
+ """
585
+
586
+ db_path: str | None
587
+ roadmap_id: str
588
+ failure_text: str
589
+
590
+
591
+ @dataclass(frozen=True)
592
+ class ResetRoadmapFailuresInput:
593
+ """Reset the consecutive-failure count for one roadmap; returns the prior count.
594
+
595
+ `db_path` is worker-local configuration; when omitted, the activity resolves
596
+ it from the worker environment in activity context (FR-002).
597
+ """
598
+
599
+ db_path: str | None
600
+ roadmap_id: str
601
+
602
+
603
+ @dataclass(frozen=True)
604
+ class RecordRoadmapFailureResult:
605
+ """Result of recording a roadmap failure: the new consecutive count (FR-010).
606
+
607
+ No escalation id is returned: roadmap failure reports are notices, not
608
+ escalations, so there is no pending row to reuse.
609
+ """
610
+
611
+ count: int
612
+
613
+
614
+ @dataclass(frozen=True)
615
+ class SendRoadmapNoticeInput:
616
+ """One roadmap notice, in the terms the operator will read it in (US2).
617
+
618
+ There is deliberately no field for a token or a chat id: a credential in an
619
+ activity input is a credential in the workflow's history forever (FR-009).
620
+ The message is the rendered notice text; the durable fact lives in the
621
+ `roadmap_failures` record written before this send is attempted.
622
+ """
623
+
624
+ roadmap_id: str
625
+ message: str
626
+
627
+
628
+ @dataclass(frozen=True)
629
+ class SentRoadmapNotice:
630
+ """What the workflow needs to know about the notice delivery (US2).
631
+
632
+ `delivered=False` means nobody was paged, and the caller continues: a notice
633
+ offers no choice, so there is no fail-safe default to apply.
634
+ """
635
+
636
+ delivered: bool
637
+
638
+
639
+ @activity.defn
640
+ async def record_roadmap_failure(request: RecordRoadmapFailureInput) -> RecordRoadmapFailureResult:
641
+ """Record a roadmap failure and return its count (FR-010).
642
+
643
+ The count is stored in the evidence store, not in workflow state, so it
644
+ survives workflow restarts and continue-as-new. Repeated identical failures
645
+ increase the count; a different failure resets it to 1.
646
+
647
+ No escalation row is written here: roadmap failures are reported as notices,
648
+ not choices, so there is nothing pending for a button press or expiry sweep
649
+ to act on (US2).
650
+ """
651
+ db_path = request.db_path or str(_store_path())
652
+ sent = datetime.now(timezone.utc)
653
+ with closing(store.connect(db_path)) as conn:
654
+ conn.executescript(_ROADMAP_FAILURES_DDL)
655
+ row = conn.execute(
656
+ "SELECT consecutive_count, last_failure_text FROM roadmap_failures WHERE roadmap_id = ?",
657
+ (request.roadmap_id,),
658
+ ).fetchone()
659
+ if row is None:
660
+ count = 1
661
+ conn.execute(
662
+ "INSERT INTO roadmap_failures (roadmap_id, consecutive_count, last_failure_text, updated_at) "
663
+ "VALUES (?, ?, ?, ?)",
664
+ (request.roadmap_id, count, request.failure_text, _iso(sent)),
665
+ )
666
+ elif row[1] == request.failure_text:
667
+ count = row[0] + 1
668
+ conn.execute(
669
+ "UPDATE roadmap_failures SET consecutive_count = ?, last_failure_text = ?, updated_at = ? "
670
+ "WHERE roadmap_id = ?",
671
+ (count, request.failure_text, _iso(sent), request.roadmap_id),
672
+ )
673
+ else:
674
+ count = 1
675
+ conn.execute(
676
+ "UPDATE roadmap_failures SET consecutive_count = ?, last_failure_text = ?, updated_at = ? "
677
+ "WHERE roadmap_id = ?",
678
+ (count, request.failure_text, _iso(sent), request.roadmap_id),
679
+ )
680
+ conn.commit()
681
+ return RecordRoadmapFailureResult(count=count)
682
+
683
+
684
+ @activity.defn
685
+ async def send_roadmap_notice(request: SendRoadmapNoticeInput) -> SentRoadmapNotice:
686
+ """Page the operator with a roadmap notice (US2).
687
+
688
+ A notice is a fact, not a choice: it carries no actions, so no transport
689
+ renders a keyboard for it, and a failed delivery is data
690
+ (`delivered=False`) rather than a raise. The durable fact lives in
691
+ `roadmap_failures`, written before this activity is invoked.
692
+ """
693
+ receipt = await _deliver(
694
+ RenderedMessage(text=request.message), request.roadmap_id
695
+ )
696
+ if not receipt.delivered:
697
+ logger.warning("roadmap notice for %s: not delivered", request.roadmap_id)
698
+ return SentRoadmapNotice(delivered=receipt.delivered)
699
+
700
+
701
+ @activity.defn
702
+ async def reset_roadmap_failures(request: ResetRoadmapFailuresInput) -> int:
703
+ """Reset the consecutive-failure count and return the prior count (FR-009).
704
+
705
+ Returns 0 when there was no record or no prior failures, so the caller can
706
+ decide whether a recovery message is warranted.
707
+ """
708
+ db_path = request.db_path or str(_store_path())
709
+ sent = datetime.now(timezone.utc)
710
+ prior = 0
711
+ with closing(store.connect(db_path)) as conn:
712
+ conn.executescript(_ROADMAP_FAILURES_DDL)
713
+ row = conn.execute(
714
+ "SELECT consecutive_count FROM roadmap_failures WHERE roadmap_id = ?",
715
+ (request.roadmap_id,),
716
+ ).fetchone()
717
+ if row is not None and row[0]:
718
+ prior = row[0]
719
+ conn.execute(
720
+ "UPDATE roadmap_failures SET consecutive_count = 0, updated_at = ? WHERE roadmap_id = ?",
721
+ (_iso(sent), request.roadmap_id),
722
+ )
723
+ conn.commit()
724
+ return prior
725
+
726
+
727
+ # --- small conversions ------------------------------------------------------
728
+
729
+
730
+ def _store_path() -> Path:
731
+ """The same `.factory/verification.db` the verification activities record to.
732
+
733
+ Escalations are evidence about a node, and an operator reading one epic opens
734
+ one database (quickstart §5).
735
+ """
736
+ from factory.env import resolve_env_path
737
+
738
+ return resolve_env_path(
739
+ ERGANE_VERIFICATION_DB_PATH_ENV,
740
+ FACTORY_VERIFICATION_DB_PATH_ENV,
741
+ DEFAULT_VERIFICATION_DB_PATH,
742
+ )
743
+
744
+
745
+ def _iso(moment: datetime) -> str:
746
+ """ISO 8601 UTC to the second — the factory's one timestamp spelling (001)."""
747
+ return moment.isoformat(timespec="seconds").replace("+00:00", "Z")
748
+
749
+
750
+ def _now_iso() -> str:
751
+ return _iso(datetime.now(timezone.utc))
752
+
753
+
754
+ def _value(item: EscalationChoice | str) -> str:
755
+ """The wire spelling of a resolution: a choice's value, or `EXPIRED` itself."""
756
+ return item.value if isinstance(item, Enum) else str(item)
757
+
758
+
759
+ # --- operator questions (008-US1) -------------------------------------------
760
+
761
+
762
+ @activity.defn
763
+ async def send_question(request: SendQuestionInput) -> SentQuestion:
764
+ """Record a question, then page the operator about it (008-US1, R11).
765
+
766
+ The mirror of `send_escalation` with the two deltas that make a question a
767
+ question: the message carries no keyboard (the operator types a reply), and
768
+ the Telegram message id the bot returns is captured into the sibling
769
+ `questions` table so a free-text answer can thread back to it (FR-008). The
770
+ row is inserted first and committed before the message is built — the same
771
+ ordering R11 turns on — so a crash in between leaves something the expiry
772
+ path (US2) can still close.
773
+
774
+ Raises `QUESTION_NOT_RECORDED` when the store refuses the row, before any
775
+ message exists. A send that fails (no token, no chat id, a refused connection)
776
+ is data, not an error: `message_id=None` is the signal that the workflow
777
+ should proceed with no reply-routing key, the way `delivered=False` is the
778
+ signal an escalation applies the fail-safe default immediately.
779
+
780
+ 041-US3: a `question_id` naming a row that exists is an *adoption* — the
781
+ ferry paged it already, so all that is left is to point its reply-routing
782
+ column at the workflow now waiting (008-US3's dedup). An id naming no row is
783
+ a fresh send with its id already chosen.
784
+ """
785
+ if request.question_id is not None:
786
+ adopted = _adopt_question(request)
787
+ if adopted is not None:
788
+ return adopted
789
+ return await _deliver_question(request)
790
+
791
+
792
+ def _adopt_question(request: SendQuestionInput) -> SentQuestion | None:
793
+ """Re-point an existing pending question row, or `None` if there is none."""
794
+ assert request.question_id is not None
795
+ with closing(_connect_question()) as conn:
796
+ existing = store.get_question(conn, request.question_id)
797
+ if existing is None or existing.resolution is not None:
798
+ return None
799
+ store.adopt_question(
800
+ conn, request.question_id, workflow_id=request.workflow_id
801
+ )
802
+ return SentQuestion(
803
+ question_id=existing.question_id,
804
+ message_id=existing.message_id,
805
+ sent_at=existing.sent_at,
806
+ expires_at=existing.expires_at,
807
+ )
808
+
809
+
810
+ async def _deliver_question(request: SendQuestionInput) -> SentQuestion:
811
+ """Record a question and page the operator — the core both paths share (US3).
812
+
813
+ The US1 terminal path calls this from the ``send_question`` activity; the
814
+ US3 in-attempt ferry calls it from the ``run_agent_attempt`` activity's
815
+ ferry callable, so a question ferried up mid-flight is recorded and paged
816
+ exactly the way a question asked at the end is (FR-002). The row-first
817
+ ordering and the message-id capture are the same, and so is the
818
+ ``QUESTION_NOT_RECORDED`` raise: a ferry that cannot record a row must not
819
+ silently ship nothing, and the adapter's isolation turns the raise into a
820
+ skipped beat rather than a hung attempt (FR-009).
821
+ """
822
+ record = _pending_question(request)
823
+
824
+ with closing(_connect_question()) as conn:
825
+ _insert_question(conn, record)
826
+
827
+ message_id = await _send_question(record)
828
+ if message_id is not None:
829
+ # Best effort, and after the fact by construction: the message is
830
+ # already out, and a row missing its message id costs a reply its
831
+ # routing key, where raising here would re-send on the next attempt.
832
+ _capture_message_id(conn, record.question_id, message_id)
833
+
834
+ return SentQuestion(
835
+ question_id=record.question_id,
836
+ message_id=message_id,
837
+ sent_at=record.sent_at,
838
+ expires_at=record.expires_at,
839
+ )
840
+
841
+
842
+ @activity.defn
843
+ async def expire_question(request: ExpireQuestionInput) -> ExpiredQuestion:
844
+ """Close out the question's window, and report what it settled on (US2's call).
845
+
846
+ The mirror of `expire_escalation`: marks the row `EXPIRED` iff it is still
847
+ pending, hands back `ANSWERED` if an operator already replied (the race the
848
+ guarded UPDATE settles), and `None` for an id the store has no record of.
849
+ Never raises on an unknown id or an unreadable store: the caller's default
850
+ must not be blocked by the same failure that lost the row.
851
+ """
852
+ try:
853
+ with closing(store.connect(_store_path())) as conn:
854
+ if store.expire_question(
855
+ conn, request.question_id, resolved_at=_now_iso()
856
+ ):
857
+ return ExpiredQuestion(final_state=store.EXPIRED)
858
+ record = store.get_question(conn, request.question_id)
859
+ except (sqlite3.Error, OSError):
860
+ logger.warning(
861
+ "question %s: store unreadable; reporting no recorded resolution",
862
+ request.question_id,
863
+ )
864
+ return ExpiredQuestion(final_state=None)
865
+
866
+ if record is None or record.resolution is None:
867
+ return ExpiredQuestion(final_state=None)
868
+ return ExpiredQuestion(final_state=record.resolution)
869
+
870
+
871
+ @activity.defn
872
+ async def settle_question(request: SettleQuestionInput) -> SettledQuestion:
873
+ """Record the operator's answer on the row — the workflow's own transition.
874
+
875
+ The mirror of `settle_escalation`, for its reason (041-US3, FR-013): until
876
+ the lifecycle was a workflow, whether an answered question's row settled
877
+ depended on the channel — the bridge writes, `ergane build answer` signalled
878
+ and left — so no question in the live store had ever been marked ANSWERED.
879
+ No arbitration beside the store's guarded UPDATE (plan trap 2): this *is*
880
+ that UPDATE, and losing it means the bridge or the window got there first.
881
+ Never raises, for the reason `expire_question` does not.
882
+ """
883
+ try:
884
+ with closing(store.connect(_store_path())) as conn:
885
+ if store.resolve_question(
886
+ conn,
887
+ request.question_id,
888
+ answer_text=request.answer_text,
889
+ resolved_at=_now_iso(),
890
+ ):
891
+ return SettledQuestion(final_state=store.ANSWERED, settled_here=True)
892
+ record = store.get_question(conn, request.question_id)
893
+ except (sqlite3.Error, OSError):
894
+ logger.warning(
895
+ "question %s: store unreadable; the answer was not recorded",
896
+ request.question_id,
897
+ )
898
+ return SettledQuestion(final_state=None, settled_here=False)
899
+
900
+ if record is None or record.resolution is None:
901
+ return SettledQuestion(final_state=None, settled_here=False)
902
+ return SettledQuestion(final_state=record.resolution, settled_here=False)
903
+
904
+
905
+ # --- the in-attempt ferry (008-US3) -------------------------------------------
906
+ #
907
+ # The ferry ships an in-flight question up and the answer down, so an agent
908
+ # that asks mid-flight keeps its process and context alive while the answer
909
+ # travels (US3). The send half reuses `_deliver_question` — a ferried question
910
+ # is recorded and paged exactly the way a terminal one is (FR-002). The answer
911
+ # half reads the store the bridge wrote to: a `message_id`-routed reply (US2)
912
+ # resolves the row `ANSWERED` with the operator's text, and this poll returns
913
+ # it. Both are callables the `run_agent_attempt` activity hands the adapter,
914
+ # the way `_usage_reader` is the spend callable — the adapter is a library leaf
915
+ # that owns the monitor loop, not the store or the notifier.
916
+
917
+
918
+ async def ferry_send_question(
919
+ workflow_id: str, epic_id: str, node_id: str, attempt: int, question_text: str
920
+ ) -> str:
921
+ """Ship an in-flight question up: record the row and page the operator (US3).
922
+
923
+ The ferry callable the adapter calls the moment it sees the agent's
924
+ `question` file. Returns the `question_id` the adapter polls for the answer
925
+ with, so the round trip routes by id and not by recency (FR-008). A send
926
+ that fails to record raises `QUESTION_NOT_RECORDED`, which the adapter's
927
+ isolation turns into a skipped beat — the question ships on the next beat,
928
+ and the agent degrades to the US1 path if the window elapses first (FR-009).
929
+ """
930
+ sent = await _deliver_question(
931
+ SendQuestionInput(
932
+ workflow_id=workflow_id,
933
+ epic_id=epic_id,
934
+ node_id=node_id,
935
+ attempt=attempt,
936
+ question_text=question_text,
937
+ )
938
+ )
939
+ return sent.question_id
940
+
941
+
942
+ async def ferry_read_answer(question_id: str) -> str | None:
943
+ """Poll the store for the operator's reply to a ferried question (US3).
944
+
945
+ The answer half of the ferry: returns the operator's verbatim answer once
946
+ the bridge has routed the reply to this question (US2's
947
+ `message_id`-threaded resolution), or ``None`` while it has not arrived. A
948
+ store that cannot be read returns ``None`` rather than raising — the
949
+ adapter's isolation would swallow a raise anyway, and ``None`` is the
950
+ signal "not yet, keep polling" the same way an unreadable spend read keeps
951
+ the previous snapshot (FR-009: a dead read never becomes a hang).
952
+ """
953
+ try:
954
+ with closing(store.connect(_store_path())) as conn:
955
+ record = store.get_question(conn, question_id)
956
+ except (sqlite3.Error, OSError):
957
+ logger.warning(
958
+ "ferry: question %s unreadable; polling again", question_id
959
+ )
960
+ return None
961
+ if record is None or record.resolution is None:
962
+ return None
963
+ return record.answer_text
964
+
965
+
966
+ @activity.defn
967
+ async def find_ferried_question(
968
+ request: FindFerriedQuestionInput,
969
+ ) -> FindFerriedQuestion:
970
+ """Did the in-attempt ferry already ship a question for this attempt? (US3).
971
+
972
+ The dedup the US1 degrade path asks before it re-sends: a question row the
973
+ ferry wrote mid-flight (and that is still unanswered, because the agent
974
+ degraded before an answer arrived) means the row and the page are already
975
+ done, and the workflow reuses that `question_id` instead of paging the
976
+ operator a second time. ``None`` means no ferry shipped for this attempt,
977
+ and the US1 path sends fresh, exactly as it did before the ferry existed.
978
+
979
+ A store that cannot be read returns ``None`` — the same posture as
980
+ `ferry_read_answer`: a dead read degrades to the fresh send (the US1 path),
981
+ never a hang (FR-009). The duplicate page that a read failure risks is a
982
+ tolerable, rare failure mode; a hang is not.
983
+ """
984
+ try:
985
+ with closing(store.connect(_store_path())) as conn:
986
+ record = store.find_pending_question_by_attempt(
987
+ conn,
988
+ epic_id=request.epic_id,
989
+ node_id=request.node_id,
990
+ attempt=request.attempt,
991
+ )
992
+ except (sqlite3.Error, OSError):
993
+ logger.warning(
994
+ "ferry: pending-question lookup for %s/%s/%s unreadable; "
995
+ "sending fresh",
996
+ request.epic_id,
997
+ request.node_id,
998
+ request.attempt,
999
+ )
1000
+ return FindFerriedQuestion(question_id=None)
1001
+ return FindFerriedQuestion(
1002
+ question_id=record.question_id if record is not None else None
1003
+ )
1004
+
1005
+
1006
+ # --- the row ----------------------------------------------------------------
1007
+
1008
+
1009
+ def _pending_question(request: SendQuestionInput) -> QuestionRecord:
1010
+ """The question as it is written down: a fresh id and its own window."""
1011
+ sent = datetime.now(timezone.utc).replace(microsecond=0)
1012
+
1013
+ return QuestionRecord(
1014
+ # 041-US3: the caller's id when it has one, a fresh 12-hex id otherwise.
1015
+ question_id=request.question_id or secrets.token_hex(6),
1016
+ workflow_id=request.workflow_id,
1017
+ epic_id=request.epic_id,
1018
+ node_id=request.node_id,
1019
+ attempt=request.attempt,
1020
+ question_text=request.question_text,
1021
+ sent_at=_iso(sent),
1022
+ expires_at=_iso(sent + timedelta(seconds=request.timeout_s)),
1023
+ message_id=None,
1024
+ )
1025
+
1026
+
1027
+ def _connect_question() -> sqlite3.Connection:
1028
+ """Open the evidence store, or fail before an untracked message exists."""
1029
+ try:
1030
+ return store.connect(_store_path())
1031
+ except (sqlite3.Error, OSError) as exc:
1032
+ raise ApplicationError(
1033
+ f"cannot open the verification store at {_store_path()}: {exc}",
1034
+ type=QUESTION_NOT_RECORDED,
1035
+ ) from exc
1036
+
1037
+
1038
+ def _insert_question(conn: sqlite3.Connection, record: QuestionRecord) -> None:
1039
+ """Write the pending row — the first half of the ordering R11 turns on."""
1040
+ try:
1041
+ store.insert_question(conn, record)
1042
+ except sqlite3.Error as exc:
1043
+ raise ApplicationError(
1044
+ f"could not record the question for node {record.node_id!r} "
1045
+ f"in epic {record.epic_id!r}: {exc}",
1046
+ type=QUESTION_NOT_RECORDED,
1047
+ ) from exc
1048
+
1049
+
1050
+ def _capture_message_id(
1051
+ conn: sqlite3.Connection, question_id: str, message_id: int
1052
+ ) -> None:
1053
+ try:
1054
+ store.capture_message_id(conn, question_id, message_id)
1055
+ except sqlite3.Error:
1056
+ logger.warning(
1057
+ "question %s: sent, but the store could not record its message id",
1058
+ question_id,
1059
+ )
1060
+
1061
+
1062
+ # --- the message ------------------------------------------------------------
1063
+
1064
+
1065
+ async def _send_question(record: QuestionRecord) -> int | None:
1066
+ """Page the operator. None means they were not paged, for any reason.
1067
+
1068
+ No actions: a question is not a choice the operator picks from a list, so
1069
+ nothing renders a keyboard for it and Telegram sends with no `reply_markup`
1070
+ (FR-008). The message handle the transport returns is the reply-routing
1071
+ key, captured into the row by the caller.
1072
+ """
1073
+ receipt = await _deliver(
1074
+ RenderedMessage(text=question_message(record)), record.question_id
1075
+ )
1076
+ if not receipt.delivered:
1077
+ logger.warning("question %s: not delivered", record.question_id)
1078
+ return receipt.message_id