@pentatonic-ai/ai-agent-sdk 0.10.38 → 0.10.39
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.
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/packages/memory-engine-v2/PLAN-async-escalation.md +497 -0
- package/packages/memory-engine-v2/RFC-async-escalation.md +218 -0
- package/packages/memory-engine-v2/extractor-async/test_async_escalation.py +314 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +173 -9
- package/packages/memory-engine-v2/org-model/migrations/014_escalated_pending.sql +22 -0
- package/packages/memory-engine-v2/scripts/redistill.py +25 -9
- package/packages/memory-engine-v2/scripts/test_redistill.py +81 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# RFC: Async Escalation — the teacher as a mop-up drainer
|
|
2
|
+
|
|
3
|
+
Status: DRAFT (2026-07-15)
|
|
4
|
+
Owner: memory-engine-v2
|
|
5
|
+
Related: RFC-student-cascade.md, RFC-fusion-drive.md, RFC-decay-and-fusion.md,
|
|
6
|
+
`scripts/redistill.py`, `modules/seesa/deploy/ops/distiller-autoscale.sh` (TES)
|
|
7
|
+
|
|
8
|
+
## 1. Summary
|
|
9
|
+
|
|
10
|
+
Take the teacher (qwen3.6-27b on the scale-to-zero L40S fleet) **off the ingest
|
|
11
|
+
critical path**. Today the cascade runs the student and, for escalated events,
|
|
12
|
+
calls the teacher **inline** — so ingest throughput is gated by the scarce,
|
|
13
|
+
capacity-blocked teacher. This RFC makes the student the always-on primary
|
|
14
|
+
drainer that writes what it's confident on immediately, and turns escalation
|
|
15
|
+
into an **async job** (`escalated_pending`) that the existing L40S fleet mops up
|
|
16
|
+
on its own schedule.
|
|
17
|
+
|
|
18
|
+
Introduce **`escalated_pending` as a first-class queue state** ("student ran,
|
|
19
|
+
teacher-only pass needed") drained by a teacher-mop-up consumer. Refactor
|
|
20
|
+
`redistill` to produce the same state instead of re-running the full cascade.
|
|
21
|
+
|
|
22
|
+
## 2. Motivation
|
|
23
|
+
|
|
24
|
+
- **Ingest capacity is teacher-bound today.** Sustainable ingest ≈
|
|
25
|
+
`teacher_capacity / escalation_rate` (~58% of events block on the teacher).
|
|
26
|
+
Async makes it `student_capacity` (student serves 100%, always-on, ~2× faster,
|
|
27
|
+
~3× measured headroom, scales on cheap L4s). Estimated ~1.5–2× steady + large
|
|
28
|
+
burst-elasticity gains; the whale-stall / g6e-capacity-block failure mode on
|
|
29
|
+
the ingest path disappears.
|
|
30
|
+
- **The eval justifies student-primary.** NuExtract-4B sits at the same-model
|
|
31
|
+
quantization ceiling (0.85/0.67/0.48/0.73 vs FP8 teacher, ≈ Qwen3.6-Q4). The
|
|
32
|
+
student is good enough to own the fast path; the teacher's job is correction of
|
|
33
|
+
the flagged minority. See [[pme2_ternary_bonsai_eval]].
|
|
34
|
+
- **Reuse, not new infra.** We already run a queue drained by a scale-to-zero
|
|
35
|
+
fleet with an age-aware autoscaler, fair-share claim, and transient-retry
|
|
36
|
+
(all hardened this session). Async escalation is the *rewiring* that lets the
|
|
37
|
+
L40S be the async mop-up drainer it was built to be.
|
|
38
|
+
|
|
39
|
+
## 3. Goals / Non-goals
|
|
40
|
+
|
|
41
|
+
**Goals**
|
|
42
|
+
- Student writes ~42% (confident) synchronously at ingest.
|
|
43
|
+
- Escalated ~58% become `escalated_pending`; teacher writes them once,
|
|
44
|
+
authoritatively, async.
|
|
45
|
+
- Zero loss of current correction (same gates, same authority split, same corpus).
|
|
46
|
+
- No supersede dependency.
|
|
47
|
+
- Instant revert via kill switch.
|
|
48
|
+
- `redistill` refactored to produce `escalated_pending` (teacher-only), fixing
|
|
49
|
+
its latent "hits the student first" gap.
|
|
50
|
+
|
|
51
|
+
**Non-goals (explicitly deferred)**
|
|
52
|
+
- Student-100%-authoritative + corpus-only teacher (that DOES need supersede).
|
|
53
|
+
- Reducing the escalation *rate* (the real capacity multiplier; separate work).
|
|
54
|
+
- Any change to fusion/decay internals.
|
|
55
|
+
- Supersede / tombstones (redistill keeps its own interim `--supersede-facts`).
|
|
56
|
+
|
|
57
|
+
## 4. Design
|
|
58
|
+
|
|
59
|
+
### 4.1 The primitive: `escalated_pending`
|
|
60
|
+
|
|
61
|
+
A new `distillation_queue.status` value meaning **"the student has run; a
|
|
62
|
+
teacher-only pass is owed."** It is NOT "reprocess from scratch" — the student
|
|
63
|
+
already produced (and, on escalation, discarded) its output; only the teacher
|
|
64
|
+
half remains. This distinguishes it from redistill's historical plain
|
|
65
|
+
`pending` re-enqueue (full-cascade redo).
|
|
66
|
+
|
|
67
|
+
Add `escalation_reason text` (parse_fail | empty | grounding | high_value |
|
|
68
|
+
random_sample | redistill) for observability and future rate-tuning.
|
|
69
|
+
|
|
70
|
+
### 4.2 Two consumers over one queue
|
|
71
|
+
|
|
72
|
+
**Student-primary consumer** (the always-on L4 worker — today's worker minus the
|
|
73
|
+
inline teacher call):
|
|
74
|
+
1. Claim `pending` via the existing fair-share claim.
|
|
75
|
+
2. Run the student on 100% of the batch.
|
|
76
|
+
3. `escalation_decision(event, sresult)`:
|
|
77
|
+
- **not escalated** → `_apply_extraction(producer="student")` + `mark_done`
|
|
78
|
+
(unchanged — today's line ~2924).
|
|
79
|
+
- **escalated** → write the student agreement-trace (unchanged, ~2908) and
|
|
80
|
+
set `status='escalated_pending', escalation_reason=<reason>` — **do NOT call
|
|
81
|
+
the teacher inline** (this replaces today's inline teacher block after
|
|
82
|
+
line ~2933).
|
|
83
|
+
|
|
84
|
+
**Teacher-mop-up consumer** (a worker-side claim loop — either the same worker
|
|
85
|
+
binary in a teacher-mode or a sibling loop — that calls the teacher vLLM on the
|
|
86
|
+
scale-to-zero **L40S fleet**; the fleet are the inference servers, the autoscaler
|
|
87
|
+
scales them on `escalated_pending` depth):
|
|
88
|
+
1. Claim `escalated_pending` (leased, `FOR UPDATE SKIP LOCKED`, `attempts` cap —
|
|
89
|
+
mirrors the existing claim).
|
|
90
|
+
2. Call the teacher vLLM exactly as the current inline path does.
|
|
91
|
+
3. `_apply_extraction(producer="teacher")` + teacher trace + `mark_done`
|
|
92
|
+
(unchanged apply path → graph writes are producer-agnostic, identical shape).
|
|
93
|
+
4. Transient errors reuse the 0.10.38 `requeue_transient` path.
|
|
94
|
+
|
|
95
|
+
Because the escalated event was **never written** by the student, the teacher's
|
|
96
|
+
write is the first and only write → **no supersede, no double-write.**
|
|
97
|
+
|
|
98
|
+
### 4.3 State machine
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
pending ──student──► done (student-confident ~42%; written by student)
|
|
102
|
+
pending ──student──► escalated_pending ──teacher──► done (escalated ~58%; written by teacher)
|
|
103
|
+
│
|
|
104
|
+
└─ transient error → escalated_pending (requeue, bounded)
|
|
105
|
+
└─ attempts exhausted → failed
|
|
106
|
+
redistill / gardener ─────► escalated_pending (teacher-only; bypasses the student)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 4.4 Kill switch
|
|
110
|
+
|
|
111
|
+
`ASYNC_ESCALATION_ENABLED` (default **false** — ship dark). When false, the
|
|
112
|
+
worker runs today's synchronous inline cascade verbatim. Flipping it false at
|
|
113
|
+
runtime instantly reverts; in-flight `escalated_pending` rows are still drained
|
|
114
|
+
by the mop-up consumer (which runs regardless), so no work is stranded.
|
|
115
|
+
|
|
116
|
+
## 5. The three wiring interactions (from the graph-cleaning fit review)
|
|
117
|
+
|
|
118
|
+
1. **Autoscaler signal.** `distiller-autoscale.sh` must count `escalated_pending`
|
|
119
|
+
toward the distill scale-up signal (currently counts `status='pending'`).
|
|
120
|
+
One-line query change; rides the age-aware scaling shipped this session.
|
|
121
|
+
`escalated_pending` is distill-class → correctly outranks fusion (autoscaler
|
|
122
|
+
already "distill drives multi-box, fusion wakes one").
|
|
123
|
+
2. **Redistill → teacher-only.** `redistill.py` currently INSERTs `status=pending`
|
|
124
|
+
(→ full cascade → student first → only reaches the teacher if it re-escalates
|
|
125
|
+
= latent gap). Change it to INSERT `status='escalated_pending',
|
|
126
|
+
escalation_reason='redistill'` → teacher-only, which is what redistill wants.
|
|
127
|
+
Redistill keeps `--supersede-facts` (it re-processes already-written events, so
|
|
128
|
+
it still needs supersede — async escalation does not inherit that).
|
|
129
|
+
3. **Priority.** `escalated_pending` inherits the event's priority
|
|
130
|
+
(fair-share/emit_ts). Fresh escalations (default/live priority) naturally
|
|
131
|
+
outrank redistill-produced ones (priority 200 background). Fusion's
|
|
132
|
+
`FUSION_FLUSH_MINUTES=360` age-flush prevents fusion starvation.
|
|
133
|
+
|
|
134
|
+
## 6. Graph-cleaning coexistence
|
|
135
|
+
|
|
136
|
+
Fusion (consume/sweep), decay, and redistill already run as async queue/timer
|
|
137
|
+
consumers sharing the fleet; async escalation makes primary distillation one
|
|
138
|
+
too. They are eventually-consistent + self-healing: the fusion consumer
|
|
139
|
+
re-validates against the live graph before each merge (no-op if the graph moved),
|
|
140
|
+
sweep/decay re-run on their timers. The async "escalated-but-not-yet-written"
|
|
141
|
+
window means a cleaning pass may see a slightly-incomplete graph and converge on
|
|
142
|
+
the next pass — no corruption. None of them touch distillation supersede.
|
|
143
|
+
|
|
144
|
+
## 7. Schema — migration 014
|
|
145
|
+
|
|
146
|
+
**Confirmed:** `distillation_queue` has a `valid_status` CHECK constraint
|
|
147
|
+
(`status = ANY['pending','claimed','done','failed']`) — it MUST be recreated to
|
|
148
|
+
admit the new value. The existing fair-share claim is `WHERE status='pending'`,
|
|
149
|
+
so it will NOT grab escalated rows (clean consumer separation, no extra guard
|
|
150
|
+
needed).
|
|
151
|
+
|
|
152
|
+
```sql
|
|
153
|
+
-- 014: escalated_pending state for async escalation.
|
|
154
|
+
ALTER TABLE distillation_queue
|
|
155
|
+
ADD COLUMN IF NOT EXISTS escalation_reason text;
|
|
156
|
+
ALTER TABLE distillation_queue DROP CONSTRAINT IF EXISTS valid_status;
|
|
157
|
+
ALTER TABLE distillation_queue ADD CONSTRAINT valid_status
|
|
158
|
+
CHECK (status = ANY (ARRAY['pending','claimed','done','failed','escalated_pending']));
|
|
159
|
+
-- mop-up claim + autoscaler count index (mirrors idx_distillation_fair_claim):
|
|
160
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_distillation_escalated
|
|
161
|
+
ON distillation_queue (priority, emit_ts, id) WHERE status = 'escalated_pending';
|
|
162
|
+
```
|
|
163
|
+
Apply to prod BEFORE the code deploy (org-model migrations aren't auto-applied;
|
|
164
|
+
same discipline as migration 013). Old code ignores the new column/status.
|
|
165
|
+
Note: `CREATE INDEX CONCURRENTLY` can't run inside the same txn as the ALTERs —
|
|
166
|
+
run the index separately. The DROP/ADD CONSTRAINT briefly takes an ACCESS
|
|
167
|
+
EXCLUSIVE lock + validates all rows; on the large prod table do it in a
|
|
168
|
+
low-traffic window (or `ADD CONSTRAINT ... NOT VALID` then `VALIDATE CONSTRAINT`
|
|
169
|
+
to avoid the long lock).
|
|
170
|
+
|
|
171
|
+
## 8. Failure, edge cases, semantics
|
|
172
|
+
|
|
173
|
+
- **Eventual consistency:** an escalated event has no graph entry between student
|
|
174
|
+
time and mop-up completion. Acceptable for a memory graph; document it.
|
|
175
|
+
- **parse_fail / empty:** already covered — these are escalation reasons →
|
|
176
|
+
`escalated_pending` → teacher produces the (only) extraction. No regression.
|
|
177
|
+
- **grounding_violation:** student output suspect → discarded (as today) → teacher
|
|
178
|
+
writes. No student write to leak the bad facts.
|
|
179
|
+
- **Mop-up backlog:** bounded by the autoscaler (age-aware) + fair-share; a
|
|
180
|
+
backlog delays escalated writes but never blocks ingest (the whole point).
|
|
181
|
+
- **Worker crash mid-mop-up:** lease expiry re-claims (existing semantics); no
|
|
182
|
+
double-write because writes are transactional + `mark_done` gated.
|
|
183
|
+
- **Corpus:** teacher still runs the escalated 58% → teacher traces written;
|
|
184
|
+
student agreement-trace still written at escalation time. Unchanged.
|
|
185
|
+
|
|
186
|
+
## 9. Testing / verification
|
|
187
|
+
|
|
188
|
+
- Unit: `escalation_decision` unchanged; new state transitions (pending→
|
|
189
|
+
escalated_pending→done; requeue; exhaustion).
|
|
190
|
+
- Integration (stub LLM): confirm student-confident → done-by-student; escalated →
|
|
191
|
+
escalated_pending → done-by-teacher; exactly one graph write per event.
|
|
192
|
+
- Prod dark-launch: enable on a canary arena, compare graph output + escalation
|
|
193
|
+
counts vs a synchronous control; watch drain, `escalated_pending` depth, and
|
|
194
|
+
the autoscaler.
|
|
195
|
+
- Verify no double-writes: per-event provenance count invariant.
|
|
196
|
+
|
|
197
|
+
## 10. Rollout
|
|
198
|
+
|
|
199
|
+
1. Migration 014 to prod.
|
|
200
|
+
2. Ship worker with `ASYNC_ESCALATION_ENABLED=false` (dark) — 0.10.x.
|
|
201
|
+
3. Autoscaler `escalated_pending` signal (TES).
|
|
202
|
+
4. Enable on a canary arena; watch ingest latency, escalated depth/drain,
|
|
203
|
+
graph-diff vs control.
|
|
204
|
+
5. Ramp to 100%; then refactor redistill → escalated_pending.
|
|
205
|
+
6. (Later, separate) chase the escalation-rate reduction for raw capacity.
|
|
206
|
+
|
|
207
|
+
## 11. Risks
|
|
208
|
+
|
|
209
|
+
- **Eventual consistency surprises** a consumer expecting immediate teacher
|
|
210
|
+
output. Mitigation: dark-launch + canary diff; kill switch.
|
|
211
|
+
- **Mop-up starvation under sustained escalation** → escalated writes lag.
|
|
212
|
+
Mitigation: age-aware autoscaler already flushes on age; alarm on
|
|
213
|
+
`escalated_pending` oldest-age.
|
|
214
|
+
- **Status-enum constraint** on `distillation_queue.status` (verify at migration
|
|
215
|
+
time; extend if constrained).
|
|
216
|
+
- **Two consumers, one table** contention — mitigated by `FOR UPDATE SKIP LOCKED`
|
|
217
|
+
+ status-partitioned claims (student claims `pending`, teacher claims
|
|
218
|
+
`escalated_pending`); they never contend for the same rows.
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import importlib.util, os, sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
EXTRACTOR = Path(__file__).parent
|
|
5
|
+
spec = importlib.util.spec_from_file_location("worker", EXTRACTOR / "worker.py")
|
|
6
|
+
|
|
7
|
+
def test_mark_escalated_pending_sql_shape(monkeypatch):
|
|
8
|
+
# Import worker with deps stubbed enough to load module-level code.
|
|
9
|
+
import worker # noqa: assumes httpx/psycopg importable in CI env
|
|
10
|
+
calls = {}
|
|
11
|
+
class FakeCur:
|
|
12
|
+
def __enter__(self): return self
|
|
13
|
+
def __exit__(self, *a): return False
|
|
14
|
+
def execute(self, sql, params): calls["sql"], calls["params"] = sql, params
|
|
15
|
+
class FakeConn:
|
|
16
|
+
def cursor(self): return FakeCur()
|
|
17
|
+
worker.mark_escalated_pending(FakeConn(), 42, "high_value")
|
|
18
|
+
assert "status = 'escalated_pending'" in calls["sql"]
|
|
19
|
+
assert "escalation_reason = %s" in calls["sql"]
|
|
20
|
+
assert "attempts" not in calls["sql"] # must NOT burn the retry budget
|
|
21
|
+
assert calls["params"] == ("high_value", 42)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_async_defers_escalated(monkeypatch):
|
|
25
|
+
import worker
|
|
26
|
+
monkeypatch.setattr(worker, "ASYNC_ESCALATION_ENABLED", True)
|
|
27
|
+
marked = []
|
|
28
|
+
monkeypatch.setattr(worker, "mark_escalated_pending",
|
|
29
|
+
lambda conn, qid, reason: marked.append((qid, reason)))
|
|
30
|
+
# escalate_items handling should route through mark_escalated_pending and
|
|
31
|
+
# NOT append to the inline teacher batch.
|
|
32
|
+
teacher_batch = worker._route_escalations(
|
|
33
|
+
conn=object(),
|
|
34
|
+
escalate_items=[{"id": 7, "event_id": "e7"}],
|
|
35
|
+
reason_by_qid={7: "high_value"},
|
|
36
|
+
)
|
|
37
|
+
assert teacher_batch == [] # nothing goes to the inline teacher
|
|
38
|
+
assert marked == [(7, "high_value")]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_sync_mode_passthrough(monkeypatch):
|
|
42
|
+
import worker
|
|
43
|
+
monkeypatch.setattr(worker, "ASYNC_ESCALATION_ENABLED", False)
|
|
44
|
+
items = [{"id": 1, "event_id": "e1"}]
|
|
45
|
+
assert worker._route_escalations(object(), items, {1: "x"}) is items
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_claim_escalated_sql(monkeypatch):
|
|
49
|
+
import worker
|
|
50
|
+
captured = {}
|
|
51
|
+
class FakeCur:
|
|
52
|
+
def __enter__(self): return self
|
|
53
|
+
def __exit__(self, *a): return False
|
|
54
|
+
def execute(self, sql, params): captured["sql"] = sql
|
|
55
|
+
def fetchall(self): return []
|
|
56
|
+
class FakeConn:
|
|
57
|
+
def cursor(self, **k): return FakeCur()
|
|
58
|
+
worker.claim_escalated_batch(FakeConn(), "w1", 16, 900, 3)
|
|
59
|
+
s = captured["sql"]
|
|
60
|
+
assert "status = 'escalated_pending'" in s
|
|
61
|
+
assert "FOR UPDATE SKIP LOCKED" in s
|
|
62
|
+
assert "claim_expires_at" in s # leased like the main claim
|
|
63
|
+
assert "attempts < %s" in s
|
|
64
|
+
assert "escalation_reason IS NOT NULL" in s # expired-claimed re-entry scoped to escalated rows
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_claim_next_batch_excludes_escalated_expired_claims(monkeypatch):
|
|
68
|
+
"""Student fair-share claim (`claim_next_batch`) must NOT be able to
|
|
69
|
+
reclaim an expired-claimed row that belongs to escalation (i.e. one whose
|
|
70
|
+
`escalation_reason IS NOT NULL`). Those rows are the sole responsibility
|
|
71
|
+
of `claim_escalated_batch`'s own expired-claimed branch (scoped with
|
|
72
|
+
`escalation_reason IS NOT NULL`, asserted in test_claim_escalated_sql
|
|
73
|
+
above). If a mop-up (teacher) worker crashes mid-batch, the row is left
|
|
74
|
+
`status='claimed'` with an expired lease and `escalation_reason` set; an
|
|
75
|
+
unguarded `claim_next_batch` expired-claimed branch would let the STUDENT
|
|
76
|
+
reclaim and write it, silently defeating the escalation. Every
|
|
77
|
+
`claim_expires_at < NOW()` branch in the claim SQL must therefore carry
|
|
78
|
+
`escalation_reason IS NULL`."""
|
|
79
|
+
import worker
|
|
80
|
+
captured: list[str] = []
|
|
81
|
+
|
|
82
|
+
class FakeCur:
|
|
83
|
+
def __enter__(self): return self
|
|
84
|
+
def __exit__(self, *a): return False
|
|
85
|
+
def execute(self, sql, params=None): captured.append(sql)
|
|
86
|
+
def fetchall(self): return []
|
|
87
|
+
|
|
88
|
+
class FakeConn:
|
|
89
|
+
def cursor(self, **k): return FakeCur()
|
|
90
|
+
|
|
91
|
+
worker.claim_next_batch(FakeConn())
|
|
92
|
+
|
|
93
|
+
# The fair-share claim query is the one with the ROW_NUMBER window
|
|
94
|
+
# function; pre-filter UPDATEs (age/garbage/sensitive/etc.) are separate
|
|
95
|
+
# statements captured earlier in the list and are irrelevant here.
|
|
96
|
+
claim_sql = next(s for s in captured if "ROW_NUMBER()" in s)
|
|
97
|
+
|
|
98
|
+
expired_claimed_branches = [
|
|
99
|
+
line for line in claim_sql.splitlines()
|
|
100
|
+
if "claim_expires_at < NOW()" in line
|
|
101
|
+
]
|
|
102
|
+
# Three branches: the per-client existence check, the LATERAL candidate
|
|
103
|
+
# scan, and the outer re-check guard.
|
|
104
|
+
assert len(expired_claimed_branches) == 3
|
|
105
|
+
for line in expired_claimed_branches:
|
|
106
|
+
assert "escalation_reason IS NULL" in line, (
|
|
107
|
+
"student claim_next_batch's expired-claimed branch is missing "
|
|
108
|
+
f"the escalation_reason IS NULL guard, so it could reclaim a "
|
|
109
|
+
f"crashed mop-up worker's escalated row: {line!r}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_requeue_transient_preserves_escalation_reason(monkeypatch):
|
|
114
|
+
"""requeue_transient (the retry path _run_teacher takes on ReadTimeout/
|
|
115
|
+
502/503/504) must NOT unconditionally send a row back to bare 'pending'.
|
|
116
|
+
An escalated row (escalation_reason set, claimed out of
|
|
117
|
+
'escalated_pending') that hits a transient infra error must return to
|
|
118
|
+
'escalated_pending' — not 'pending', where the STUDENT's claim_next_batch
|
|
119
|
+
(unconditional status='pending' branch) can claim and write it, silently
|
|
120
|
+
defeating the escalation. A normal (non-escalated) row must still return
|
|
121
|
+
to plain 'pending' so the student can reclaim it."""
|
|
122
|
+
import worker
|
|
123
|
+
captured = {}
|
|
124
|
+
|
|
125
|
+
class FakeCur:
|
|
126
|
+
def __enter__(self): return self
|
|
127
|
+
def __exit__(self, *a): return False
|
|
128
|
+
def execute(self, sql, params): captured["sql"], captured["params"] = sql, params
|
|
129
|
+
|
|
130
|
+
class FakeConn:
|
|
131
|
+
def cursor(self): return FakeCur()
|
|
132
|
+
|
|
133
|
+
worker.requeue_transient(FakeConn(), 42, transient_attempts=0, error="ReadTimeout")
|
|
134
|
+
sql = captured["sql"]
|
|
135
|
+
assert "status = 'pending'" not in sql, (
|
|
136
|
+
"requeue_transient must not unconditionally set status='pending' — "
|
|
137
|
+
"an escalated row would lose its escalation on a transient retry"
|
|
138
|
+
)
|
|
139
|
+
assert "escalation_reason IS NOT NULL" in sql
|
|
140
|
+
assert "'escalated_pending'" in sql
|
|
141
|
+
assert "'pending'" in sql # the CASE's ELSE branch for non-escalated rows
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_release_claim_preserves_escalation_reason(monkeypatch):
|
|
145
|
+
"""release_claim (the retry path _run_teacher takes on non-exhausted
|
|
146
|
+
genuine failures) has the same bug: it must preserve escalation_reason by
|
|
147
|
+
routing an escalated row back to 'escalated_pending', not 'pending'."""
|
|
148
|
+
import worker
|
|
149
|
+
captured = {}
|
|
150
|
+
|
|
151
|
+
class FakeCur:
|
|
152
|
+
def __enter__(self): return self
|
|
153
|
+
def __exit__(self, *a): return False
|
|
154
|
+
def execute(self, sql, params): captured["sql"], captured["params"] = sql, params
|
|
155
|
+
|
|
156
|
+
class FakeConn:
|
|
157
|
+
def cursor(self): return FakeCur()
|
|
158
|
+
|
|
159
|
+
worker.release_claim(FakeConn(), 42, "boom")
|
|
160
|
+
sql = captured["sql"]
|
|
161
|
+
assert "status = 'pending'" not in sql, (
|
|
162
|
+
"release_claim must not unconditionally set status='pending' — "
|
|
163
|
+
"an escalated row would lose its escalation on retry"
|
|
164
|
+
)
|
|
165
|
+
assert "escalation_reason IS NOT NULL" in sql
|
|
166
|
+
assert "'escalated_pending'" in sql
|
|
167
|
+
assert "'pending'" in sql # the CASE's ELSE branch for non-escalated rows
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_end_to_end_escalated_written_once(monkeypatch):
|
|
171
|
+
"""One escalated event flows pending -> escalated_pending -> done, written
|
|
172
|
+
exactly once by the teacher.
|
|
173
|
+
|
|
174
|
+
NOTE on fixture choice: this suite has no real-Postgres fixture (see
|
|
175
|
+
test_queue_attempts.py's own comment: "The DB-touching claim/release/fail
|
|
176
|
+
SQL isn't unit-testable here (no DB in this suite)"), and this task runs
|
|
177
|
+
under an explicit "do not touch any database" constraint — so this is a
|
|
178
|
+
mock-based integration test, not a real-PG one. The FakeConn/FakeCur below
|
|
179
|
+
recognize worker.py's OWN SQL literals for mark_escalated_pending,
|
|
180
|
+
claim_escalated_batch, and mark_done (the same literals the SQL-shape
|
|
181
|
+
tests above pin) and mutate a one-row in-memory table accordingly — so the
|
|
182
|
+
real `mark_escalated_pending` / `claim_escalated_batch` / `_run_teacher` /
|
|
183
|
+
`_apply_extraction` / `mark_done` code all runs unmodified; only the LLM
|
|
184
|
+
call (call_llm_batch) and the graph upserts (upsert_entities/facts/
|
|
185
|
+
relationships) are stubbed, standing in for the teacher vLLM + org-model
|
|
186
|
+
Postgres this worker talks to in prod."""
|
|
187
|
+
import asyncio
|
|
188
|
+
import worker
|
|
189
|
+
|
|
190
|
+
monkeypatch.setattr(worker, "ASYNC_ESCALATION_ENABLED", True)
|
|
191
|
+
monkeypatch.setattr(worker, "_TEACHER_SEM", asyncio.Semaphore(4))
|
|
192
|
+
|
|
193
|
+
row = {
|
|
194
|
+
"id": 1, "event_id": "e1", "status": "pending", "attempts": 0,
|
|
195
|
+
"transient_attempts": 0, "escalation_reason": None,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
class FakeCur:
|
|
199
|
+
def __init__(self, table):
|
|
200
|
+
self.table = table
|
|
201
|
+
self._rows: list[dict] = []
|
|
202
|
+
|
|
203
|
+
def __enter__(self):
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def __exit__(self, *a):
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def execute(self, sql, params=None):
|
|
210
|
+
r = self.table
|
|
211
|
+
if "status = 'escalated_pending'" in sql and "escalation_reason = %s" in sql:
|
|
212
|
+
# mark_escalated_pending (student defer, Task 2/3)
|
|
213
|
+
reason, qid = params
|
|
214
|
+
assert qid == r["id"]
|
|
215
|
+
r.update(status="escalated_pending", escalation_reason=reason,
|
|
216
|
+
claimed_by=None)
|
|
217
|
+
self._rows = []
|
|
218
|
+
elif "escalation_reason IS NOT NULL" in sql:
|
|
219
|
+
# claim_escalated_batch (this task's mop-up claim)
|
|
220
|
+
worker_id, ttl, max_attempts, batch_size = params
|
|
221
|
+
if r["status"] == "escalated_pending" and r["attempts"] < max_attempts:
|
|
222
|
+
r["status"] = "claimed"
|
|
223
|
+
r["claimed_by"] = worker_id
|
|
224
|
+
self._rows = [dict(
|
|
225
|
+
id=r["id"], event_id=r["event_id"], attempts=r["attempts"],
|
|
226
|
+
transient_attempts=r["transient_attempts"],
|
|
227
|
+
escalation_reason=r["escalation_reason"],
|
|
228
|
+
)]
|
|
229
|
+
else:
|
|
230
|
+
self._rows = []
|
|
231
|
+
elif "status = 'done'" in sql:
|
|
232
|
+
# mark_done (teacher write completes)
|
|
233
|
+
(qid,) = params
|
|
234
|
+
assert qid == r["id"]
|
|
235
|
+
r["status"] = "done"
|
|
236
|
+
self._rows = []
|
|
237
|
+
else:
|
|
238
|
+
raise AssertionError(f"test mock hit unrecognised SQL: {sql[:120]!r}")
|
|
239
|
+
|
|
240
|
+
def fetchall(self):
|
|
241
|
+
return self._rows
|
|
242
|
+
|
|
243
|
+
def fetchone(self):
|
|
244
|
+
return self._rows[0] if self._rows else None
|
|
245
|
+
|
|
246
|
+
class FakeConn:
|
|
247
|
+
def __init__(self, table):
|
|
248
|
+
self.table = table
|
|
249
|
+
|
|
250
|
+
def cursor(self, **k):
|
|
251
|
+
return FakeCur(self.table)
|
|
252
|
+
|
|
253
|
+
def transaction(self):
|
|
254
|
+
import contextlib
|
|
255
|
+
return contextlib.nullcontext()
|
|
256
|
+
|
|
257
|
+
conn = FakeConn(row)
|
|
258
|
+
|
|
259
|
+
def queue_status():
|
|
260
|
+
return row["status"]
|
|
261
|
+
|
|
262
|
+
# --- Step 1: student-primary defers the event (Task 3's path). No write,
|
|
263
|
+
# attempts untouched.
|
|
264
|
+
worker.mark_escalated_pending(conn, row["id"], "high_value")
|
|
265
|
+
assert queue_status() == "escalated_pending"
|
|
266
|
+
|
|
267
|
+
# --- Step 2: teacher-mop-up claims it (this task's claim_escalated_batch).
|
|
268
|
+
claimed = worker.claim_escalated_batch(conn, "mopup-w1", 16, 900, 3)
|
|
269
|
+
assert len(claimed) == 1
|
|
270
|
+
assert claimed[0]["event_id"] == "e1"
|
|
271
|
+
assert claimed[0]["escalation_reason"] == "high_value"
|
|
272
|
+
assert queue_status() == "claimed"
|
|
273
|
+
|
|
274
|
+
# --- Step 3: run the claimed batch through the SAME teacher path the
|
|
275
|
+
# inline cascade escalation uses (_run_teacher -> _apply_extraction
|
|
276
|
+
# producer="teacher" -> mark_done). Stub the LLM + graph upserts.
|
|
277
|
+
event = {
|
|
278
|
+
"id": "e1", "arena": "acme", "source_kind": "email",
|
|
279
|
+
"content": "We will ship Friday.", "attributes": {},
|
|
280
|
+
"participant_set": ["acme"], "disclosure_class": "private",
|
|
281
|
+
"emitted_at": None,
|
|
282
|
+
}
|
|
283
|
+
events_by_qid = {row["id"]: event}
|
|
284
|
+
reason_by_qid = {row["id"]: claimed[0]["escalation_reason"]}
|
|
285
|
+
|
|
286
|
+
write_calls: list[str] = []
|
|
287
|
+
monkeypatch.setattr(
|
|
288
|
+
worker, "upsert_entities",
|
|
289
|
+
lambda *a, **k: (write_calls.append("entities"), {})[1],
|
|
290
|
+
)
|
|
291
|
+
monkeypatch.setattr(
|
|
292
|
+
worker, "upsert_facts",
|
|
293
|
+
lambda *a, **k: (write_calls.append("facts"), 1)[1],
|
|
294
|
+
)
|
|
295
|
+
monkeypatch.setattr(
|
|
296
|
+
worker, "upsert_relationships",
|
|
297
|
+
lambda *a, **k: (write_calls.append("relationships"), 0)[1],
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
async def fake_call_llm_batch(http, events):
|
|
301
|
+
return [
|
|
302
|
+
{"entities": [], "facts": [{"statement": "shipped Friday"}], "relationships": []}
|
|
303
|
+
for _ in events
|
|
304
|
+
]
|
|
305
|
+
monkeypatch.setattr(worker, "call_llm_batch", fake_call_llm_batch)
|
|
306
|
+
|
|
307
|
+
asyncio.run(worker._run_teacher(
|
|
308
|
+
http=None, conn=conn, teacher_items=claimed, events_by_qid=events_by_qid,
|
|
309
|
+
stub_mode=False, reason_by_qid=reason_by_qid,
|
|
310
|
+
))
|
|
311
|
+
|
|
312
|
+
assert queue_status() == "done"
|
|
313
|
+
# written exactly once, by teacher — one call each to the three upserts
|
|
314
|
+
assert write_calls == ["entities", "facts", "relationships"]
|