@pentatonic-ai/ai-agent-sdk 0.10.37 → 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.
@@ -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"]