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.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- 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
|