nexo-brain 7.23.13 → 7.24.0
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/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -11
- package/bin/nexo-brain.js +42 -235
- package/package.json +1 -1
- package/src/automation_supervisor.py +1 -1
- package/src/cli.py +255 -9
- package/src/cognitive_control_observatory.py +224 -0
- package/src/dashboard/app.py +26 -9
- package/src/db/__init__.py +2 -0
- package/src/db/_learnings.py +1 -1
- package/src/db/_memory_v2.py +107 -1
- package/src/db/_protocol.py +2 -2
- package/src/db/_reminders.py +132 -4
- package/src/db/_schema.py +2 -2
- package/src/events_bus.py +4 -5
- package/src/learning_resolver.py +419 -0
- package/src/lifecycle_events.py +9 -9
- package/src/local_context/api.py +67 -5
- package/src/local_context/usage_events.py +24 -0
- package/src/memory_observation_processor.py +28 -0
- package/src/memory_retrieval.py +5 -5
- package/src/operator_language.py +2 -0
- package/src/plugins/backup.py +1 -1
- package/src/plugins/cortex.py +21 -21
- package/src/plugins/episodic_memory.py +11 -11
- package/src/plugins/goal_engine.py +3 -3
- package/src/plugins/personal_scripts.py +75 -0
- package/src/plugins/protocol.py +10 -1
- package/src/pre_answer_router.py +116 -0
- package/src/r_catalog.py +4 -5
- package/src/saved_not_used_audit.py +31 -31
- package/src/script_registry.py +444 -1
- package/src/scripts/deep-sleep/apply_findings.py +79 -17
- package/src/scripts/nexo-daily-self-audit.py +46 -13
- package/src/scripts/nexo-email-migrate-config.py +2 -2
- package/src/scripts/nexo-email-monitor.py +19 -19
- package/src/scripts/nexo-followup-hygiene.py +40 -8
- package/src/scripts/nexo-followup-runner.py +31 -31
- package/src/scripts/nexo-inbox-hook.sh +1 -1
- package/src/scripts/nexo-learning-validator.py +24 -3
- package/src/server.py +73 -1
- package/src/system_catalog.py +31 -31
- package/src/tools_learnings.py +96 -65
- package/src/tools_memory_v2.py +2 -2
- package/src/tools_sessions.py +25 -7
- package/templates/core-prompts/postmortem-consolidator.md +3 -3
- package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
- package/templates/core-prompts/server-mcp-instructions.md +6 -6
- package/tool-enforcement-map.json +143 -13
package/src/db/_memory_v2.py
CHANGED
|
@@ -9,6 +9,7 @@ stable substrate without changing hook behaviour again.
|
|
|
9
9
|
import hashlib
|
|
10
10
|
import importlib
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
12
13
|
import re
|
|
13
14
|
import sqlite3
|
|
14
15
|
import sys
|
|
@@ -429,6 +430,101 @@ def _derive_observation(event: dict) -> dict:
|
|
|
429
430
|
}
|
|
430
431
|
|
|
431
432
|
|
|
433
|
+
def _intraday_facts_enabled() -> bool:
|
|
434
|
+
value = os.environ.get("NEXO_INTRADAY_FACTS_ENABLED", "1").strip().lower()
|
|
435
|
+
return value not in {"0", "false", "no", "off", "disabled"}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _intraday_fact_candidate(observation: dict) -> bool:
|
|
439
|
+
if str(observation.get("status") or "active").lower() != "active":
|
|
440
|
+
return False
|
|
441
|
+
if float(observation.get("salience") or 0.0) < 0.62:
|
|
442
|
+
return False
|
|
443
|
+
if str(observation.get("observation_type") or "") not in {
|
|
444
|
+
"code_change",
|
|
445
|
+
"correction",
|
|
446
|
+
"decision",
|
|
447
|
+
"task_result",
|
|
448
|
+
}:
|
|
449
|
+
return False
|
|
450
|
+
if not str(observation.get("summary") or "").strip():
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
facts = observation.get("facts") if isinstance(observation.get("facts"), dict) else {}
|
|
454
|
+
metadata = facts.get("metadata") if isinstance(facts.get("metadata"), dict) else {}
|
|
455
|
+
event_type = str(facts.get("event_type") or "")
|
|
456
|
+
source_type = str(facts.get("source_type") or "")
|
|
457
|
+
refs = [str(ref) for ref in observation.get("evidence_refs") or []]
|
|
458
|
+
observation_type = str(observation.get("observation_type") or "")
|
|
459
|
+
|
|
460
|
+
if observation_type == "task_result":
|
|
461
|
+
outcome = str(metadata.get("outcome") or event_type.removeprefix("protocol_task_") or "").lower()
|
|
462
|
+
return outcome in {"done", "closed", "completed", "success", "partial"}
|
|
463
|
+
if observation_type == "code_change":
|
|
464
|
+
verification_keys = {
|
|
465
|
+
"verified",
|
|
466
|
+
"verification",
|
|
467
|
+
"change_verify",
|
|
468
|
+
"test_output",
|
|
469
|
+
"tests_passed",
|
|
470
|
+
"evidence",
|
|
471
|
+
}
|
|
472
|
+
if source_type in {"change_log", "evidence_ledger", "protocol_task"}:
|
|
473
|
+
return True
|
|
474
|
+
if any(key in metadata and str(metadata.get(key) or "").strip() for key in verification_keys):
|
|
475
|
+
return True
|
|
476
|
+
return any(ref.startswith(("change_log:", "evidence:", "protocol_task:")) for ref in refs)
|
|
477
|
+
return observation_type in {"correction", "decision"}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def publish_intraday_fact(observation: dict, *, ttl_hours: int = 36) -> dict:
|
|
481
|
+
"""Expose high-salience observations as temporary hot context.
|
|
482
|
+
|
|
483
|
+
This is deliberately not long-term promotion. Deep Sleep can later promote,
|
|
484
|
+
merge, or discard the observation; the intraday fact only keeps today's
|
|
485
|
+
important work visible while the operator keeps working.
|
|
486
|
+
"""
|
|
487
|
+
if not _intraday_facts_enabled():
|
|
488
|
+
return {"ok": True, "skipped": True, "reason": "intraday facts disabled"}
|
|
489
|
+
if not _intraday_fact_candidate(observation):
|
|
490
|
+
return {"ok": True, "skipped": True, "reason": "not an intraday fact candidate"}
|
|
491
|
+
|
|
492
|
+
uid = str(observation.get("observation_uid") or "").strip()
|
|
493
|
+
if not uid:
|
|
494
|
+
return {"ok": False, "error": "observation_uid is required"}
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
from db._hot_context import capture_context_event
|
|
498
|
+
|
|
499
|
+
result = capture_context_event(
|
|
500
|
+
event_type="intraday_fact",
|
|
501
|
+
title=_truncate(observation.get("subject") or uid, 160),
|
|
502
|
+
summary=_truncate(observation.get("summary") or "", 600),
|
|
503
|
+
body=_truncate(observation.get("summary") or "", 1600),
|
|
504
|
+
context_key=f"intraday_fact:{uid}",
|
|
505
|
+
context_title=_truncate(observation.get("subject") or uid, 160),
|
|
506
|
+
context_summary=_truncate(observation.get("summary") or "", 600),
|
|
507
|
+
context_type="intraday_fact",
|
|
508
|
+
state="active",
|
|
509
|
+
owner="nexo",
|
|
510
|
+
actor="memory-observation-processor",
|
|
511
|
+
source_type="memory_observation",
|
|
512
|
+
source_id=uid,
|
|
513
|
+
session_id=str(observation.get("session_id") or ""),
|
|
514
|
+
metadata={
|
|
515
|
+
"observation_type": observation.get("observation_type") or "",
|
|
516
|
+
"project_key": observation.get("project_key") or "",
|
|
517
|
+
"promotion_state": observation.get("promotion_state") or "observation",
|
|
518
|
+
"evidence_refs": observation.get("evidence_refs") or [],
|
|
519
|
+
},
|
|
520
|
+
ttl_hours=ttl_hours,
|
|
521
|
+
created_at=float(observation.get("updated_at") or _core().now_epoch()),
|
|
522
|
+
)
|
|
523
|
+
return {"ok": True, "context_key": result.get("context_key"), "result": result}
|
|
524
|
+
except Exception as exc:
|
|
525
|
+
return {"ok": False, "error": _truncate(str(exc), 500)}
|
|
526
|
+
|
|
527
|
+
|
|
432
528
|
def upsert_memory_observation(observation: dict) -> dict:
|
|
433
529
|
conn = _core().get_db()
|
|
434
530
|
if not _table_exists(conn, "memory_observations"):
|
|
@@ -520,6 +616,7 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
|
|
|
520
616
|
).fetchall()
|
|
521
617
|
processed = 0
|
|
522
618
|
failed = 0
|
|
619
|
+
intraday_facts = 0
|
|
523
620
|
now = _core().now_epoch()
|
|
524
621
|
for row in rows:
|
|
525
622
|
event = _row_to_event(row)
|
|
@@ -527,6 +624,9 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
|
|
|
527
624
|
try:
|
|
528
625
|
observation = _derive_observation(event)
|
|
529
626
|
upsert_memory_observation(observation)
|
|
627
|
+
intraday_result = publish_intraday_fact(observation)
|
|
628
|
+
if intraday_result.get("ok") and not intraday_result.get("skipped"):
|
|
629
|
+
intraday_facts += 1
|
|
530
630
|
conn.execute(
|
|
531
631
|
"""
|
|
532
632
|
UPDATE memory_observation_queue
|
|
@@ -554,7 +654,13 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
|
|
|
554
654
|
)
|
|
555
655
|
failed += 1
|
|
556
656
|
conn.commit()
|
|
557
|
-
return {
|
|
657
|
+
return {
|
|
658
|
+
"ok": failed == 0,
|
|
659
|
+
"processed": processed,
|
|
660
|
+
"failed": failed,
|
|
661
|
+
"intraday_facts": intraday_facts,
|
|
662
|
+
"total_seen": len(rows),
|
|
663
|
+
}
|
|
558
664
|
|
|
559
665
|
|
|
560
666
|
def list_memory_events(
|
package/src/db/_protocol.py
CHANGED
|
@@ -365,9 +365,9 @@ def cortex_evaluation_summary(days: int = 30) -> dict:
|
|
|
365
365
|
|
|
366
366
|
gaps: list[str] = []
|
|
367
367
|
if total < 3:
|
|
368
|
-
gaps.append("
|
|
368
|
+
gaps.append("Too few Cortex evaluations to infer stable improvement.")
|
|
369
369
|
if len(linked) < 2:
|
|
370
|
-
gaps.append("
|
|
370
|
+
gaps.append("Too few decisions linked to outcomes to measure real recommendation quality.")
|
|
371
371
|
|
|
372
372
|
return {
|
|
373
373
|
"days": max(1, int(days)),
|
package/src/db/_reminders.py
CHANGED
|
@@ -14,7 +14,53 @@ from db._hot_context import capture_context_event
|
|
|
14
14
|
from db._learnings import extract_keywords
|
|
15
15
|
from db._semantic_similarity import hybrid_similarity_score
|
|
16
16
|
|
|
17
|
-
ACTIVE_EXCLUDED_STATUSES = {
|
|
17
|
+
ACTIVE_EXCLUDED_STATUSES = {
|
|
18
|
+
"ARCHIVED",
|
|
19
|
+
"BLOCKED",
|
|
20
|
+
"DELETED",
|
|
21
|
+
"DONE",
|
|
22
|
+
"EXPIRED",
|
|
23
|
+
"NEEDS_DECISION",
|
|
24
|
+
"PARKED",
|
|
25
|
+
"STALE_REVIEW",
|
|
26
|
+
"WAITING",
|
|
27
|
+
"WAITING_EXTERNAL",
|
|
28
|
+
"WAITING_USER",
|
|
29
|
+
}
|
|
30
|
+
FOLLOWUP_TERMINAL_STATUSES = {"COMPLETED", "DELETED", "DONE", "EXPIRED", "ARCHIVED"}
|
|
31
|
+
FOLLOWUP_WAITING_USER_STATUSES = {"NEEDS_DECISION", "WAITING_USER"}
|
|
32
|
+
FOLLOWUP_WAITING_EXTERNAL_STATUSES = {"WAITING", "WAITING_EXTERNAL"}
|
|
33
|
+
FOLLOWUP_BLOCKED_STATUSES = {"BLOCKED"}
|
|
34
|
+
FOLLOWUP_STATUS_ALIASES = {
|
|
35
|
+
"": "PENDING",
|
|
36
|
+
"PENDIENTE": "PENDING",
|
|
37
|
+
"PENDING": "PENDING",
|
|
38
|
+
"ACTIVE": "PENDING",
|
|
39
|
+
"ACTIVO": "PENDING",
|
|
40
|
+
"COMPLETADO": "COMPLETED",
|
|
41
|
+
"COMPLETED": "COMPLETED",
|
|
42
|
+
"DONE": "DONE",
|
|
43
|
+
"HECHO": "DONE",
|
|
44
|
+
"ELIMINADO": "DELETED",
|
|
45
|
+
"DELETED": "DELETED",
|
|
46
|
+
"ARCHIVED": "ARCHIVED",
|
|
47
|
+
"ARCHIVADO": "ARCHIVED",
|
|
48
|
+
"BLOCKED": "BLOCKED",
|
|
49
|
+
"BLOQUEADO": "BLOCKED",
|
|
50
|
+
"WAITING": "WAITING",
|
|
51
|
+
"WAITING_EXTERNAL": "WAITING_EXTERNAL",
|
|
52
|
+
"ESPERANDO": "WAITING_EXTERNAL",
|
|
53
|
+
"WAITING_USER": "WAITING_USER",
|
|
54
|
+
"NEEDS_DECISION": "NEEDS_DECISION",
|
|
55
|
+
"NEEDS-DECISION": "NEEDS_DECISION",
|
|
56
|
+
"NEEDS DECISION": "NEEDS_DECISION",
|
|
57
|
+
"DECISION": "NEEDS_DECISION",
|
|
58
|
+
"NECESITA_DECISION": "NEEDS_DECISION",
|
|
59
|
+
"PARKED": "PARKED",
|
|
60
|
+
"APARCADO": "PARKED",
|
|
61
|
+
"STALE_REVIEW": "STALE_REVIEW",
|
|
62
|
+
"EXPIRED": "EXPIRED",
|
|
63
|
+
}
|
|
18
64
|
READ_TOKEN_TTL_SECONDS = 30 * 60
|
|
19
65
|
|
|
20
66
|
# Opportunistic cleanup of expired item_read_tokens: runs at most once every
|
|
@@ -71,6 +117,49 @@ def _truncate(text: str | None, limit: int = 240) -> str:
|
|
|
71
117
|
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
72
118
|
|
|
73
119
|
|
|
120
|
+
def normalize_followup_status(status: str | None) -> str:
|
|
121
|
+
"""Return the canonical followup status used by lifecycle tools."""
|
|
122
|
+
clean = str(status or "").strip()
|
|
123
|
+
if not clean:
|
|
124
|
+
return "PENDING"
|
|
125
|
+
upper = clean.upper().replace("-", "_")
|
|
126
|
+
if upper.startswith("COMPLETED"):
|
|
127
|
+
return "COMPLETED"
|
|
128
|
+
return FOLLOWUP_STATUS_ALIASES.get(upper, upper)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def followup_lifecycle_lane(followup: dict) -> str:
|
|
132
|
+
"""Classify one followup into an operational lane without mutating it."""
|
|
133
|
+
status = normalize_followup_status(followup.get("status"))
|
|
134
|
+
owner = str(followup.get("owner") or "").strip().lower()
|
|
135
|
+
if status in FOLLOWUP_TERMINAL_STATUSES:
|
|
136
|
+
return "completed" if status in {"COMPLETED", "DONE"} else status.lower()
|
|
137
|
+
if status in FOLLOWUP_BLOCKED_STATUSES:
|
|
138
|
+
return "blocked"
|
|
139
|
+
if status in FOLLOWUP_WAITING_USER_STATUSES or owner == "user":
|
|
140
|
+
return "waiting_user"
|
|
141
|
+
if status in FOLLOWUP_WAITING_EXTERNAL_STATUSES or owner == "waiting":
|
|
142
|
+
return "waiting_external"
|
|
143
|
+
if status == "PARKED":
|
|
144
|
+
return "parked"
|
|
145
|
+
if status == "STALE_REVIEW":
|
|
146
|
+
return "stale_review"
|
|
147
|
+
return "active"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def followup_due_state(followup: dict) -> str:
|
|
151
|
+
"""Return due/backlog/future/completed without changing the lifecycle lane."""
|
|
152
|
+
lane = followup_lifecycle_lane(followup)
|
|
153
|
+
if lane != "active":
|
|
154
|
+
return lane
|
|
155
|
+
due = _parse_date(followup.get("date"))
|
|
156
|
+
if due is None:
|
|
157
|
+
return "backlog"
|
|
158
|
+
if due > datetime.date.today():
|
|
159
|
+
return "future"
|
|
160
|
+
return "due"
|
|
161
|
+
|
|
162
|
+
|
|
74
163
|
def _format_changes(before: sqlite3.Row | dict | None, after: sqlite3.Row | dict | None, fields: list[str]) -> str:
|
|
75
164
|
if before is None or after is None:
|
|
76
165
|
return ""
|
|
@@ -226,18 +315,20 @@ def _active_status_where(column_name: str = "status") -> str:
|
|
|
226
315
|
excluded = ", ".join(f"'{value}'" for value in sorted(ACTIVE_EXCLUDED_STATUSES))
|
|
227
316
|
return (
|
|
228
317
|
f"{column_name} NOT LIKE 'COMPLETED%' "
|
|
229
|
-
f"AND {column_name} NOT IN ({excluded})"
|
|
318
|
+
f"AND UPPER(COALESCE({column_name}, '')) NOT IN ({excluded})"
|
|
230
319
|
)
|
|
231
320
|
|
|
232
321
|
|
|
233
322
|
def _context_state_from_status(status: str | None) -> str:
|
|
234
|
-
normalized =
|
|
323
|
+
normalized = normalize_followup_status(status)
|
|
235
324
|
if normalized.startswith("COMPLETED"):
|
|
236
325
|
return "resolved"
|
|
237
326
|
if normalized == "DELETED":
|
|
238
327
|
return "abandoned"
|
|
239
|
-
if normalized
|
|
328
|
+
if normalized in {"WAITING_USER", "NEEDS_DECISION"}:
|
|
240
329
|
return "waiting_user"
|
|
330
|
+
if normalized in {"WAITING", "WAITING_EXTERNAL"}:
|
|
331
|
+
return "waiting_third_party"
|
|
241
332
|
if normalized == "BLOCKED":
|
|
242
333
|
return "blocked"
|
|
243
334
|
return "active"
|
|
@@ -632,6 +723,7 @@ def create_followup(
|
|
|
632
723
|
"""
|
|
633
724
|
conn = get_db()
|
|
634
725
|
now = now_epoch()
|
|
726
|
+
status = normalize_followup_status(status)
|
|
635
727
|
similar = find_similar_followups(description)
|
|
636
728
|
warning = ""
|
|
637
729
|
if similar:
|
|
@@ -742,6 +834,8 @@ def update_followup(
|
|
|
742
834
|
updates.pop("owner")
|
|
743
835
|
else:
|
|
744
836
|
updates["owner"] = coerced
|
|
837
|
+
if "status" in updates:
|
|
838
|
+
updates["status"] = normalize_followup_status(updates["status"])
|
|
745
839
|
if not updates:
|
|
746
840
|
return {"error": "No valid fields to update"}
|
|
747
841
|
|
|
@@ -1102,6 +1196,40 @@ def get_followup_history(id: str, limit: int = 20) -> list[dict]:
|
|
|
1102
1196
|
return get_item_history("followup", id, limit=limit)
|
|
1103
1197
|
|
|
1104
1198
|
|
|
1199
|
+
def followup_lifecycle_snapshot(limit: int = 500) -> dict:
|
|
1200
|
+
"""Return followups grouped by lifecycle lane for dashboards and runners."""
|
|
1201
|
+
conn = get_db()
|
|
1202
|
+
rows = conn.execute(
|
|
1203
|
+
"SELECT * FROM followups ORDER BY updated_at DESC LIMIT ?",
|
|
1204
|
+
(max(1, min(int(limit or 500), 5000)),),
|
|
1205
|
+
).fetchall()
|
|
1206
|
+
lanes = {
|
|
1207
|
+
"active": [],
|
|
1208
|
+
"waiting_user": [],
|
|
1209
|
+
"waiting_external": [],
|
|
1210
|
+
"blocked": [],
|
|
1211
|
+
"parked": [],
|
|
1212
|
+
"stale_review": [],
|
|
1213
|
+
"expired": [],
|
|
1214
|
+
"completed": [],
|
|
1215
|
+
"deleted": [],
|
|
1216
|
+
"archived": [],
|
|
1217
|
+
}
|
|
1218
|
+
for row in rows:
|
|
1219
|
+
item = dict(row)
|
|
1220
|
+
item["status"] = normalize_followup_status(item.get("status"))
|
|
1221
|
+
lane = followup_lifecycle_lane(item)
|
|
1222
|
+
item["lifecycle_lane"] = lane
|
|
1223
|
+
item["due_state"] = followup_due_state(item)
|
|
1224
|
+
lanes.setdefault(lane, []).append(item)
|
|
1225
|
+
return {
|
|
1226
|
+
"ok": True,
|
|
1227
|
+
"total": len(rows),
|
|
1228
|
+
"lanes": lanes,
|
|
1229
|
+
"counts": {lane: len(items) for lane, items in lanes.items()},
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
|
|
1105
1233
|
def _parse_date(date_str: str | None) -> datetime.date | None:
|
|
1106
1234
|
text = str(date_str or "").strip()
|
|
1107
1235
|
if not text:
|
package/src/db/_schema.py
CHANGED
|
@@ -1341,7 +1341,7 @@ def _m49_protocol_guard_ack_backfill(conn):
|
|
|
1341
1341
|
|
|
1342
1342
|
def _m50_dedupe_nexo_product_learning_pair(conn):
|
|
1343
1343
|
"""Block D.2 / G7-adjacent: dedupe the two learnings that encode the
|
|
1344
|
-
"NEXO Brain
|
|
1344
|
+
"NEXO Brain public product vs Francisco's personal instance"
|
|
1345
1345
|
invariant as a physically separate pair.
|
|
1346
1346
|
|
|
1347
1347
|
Francisco's runtime has this concept stored twice (historical IDs 212
|
|
@@ -1368,7 +1368,7 @@ def _m50_dedupe_nexo_product_learning_pair(conn):
|
|
|
1368
1368
|
|
|
1369
1369
|
def _norm(text: str) -> str:
|
|
1370
1370
|
# Collapse whitespace and strip punctuation/case so "NEXO Brain
|
|
1371
|
-
#
|
|
1371
|
+
# public product vs personal instance" matches its twin no
|
|
1372
1372
|
# matter how the operator rephrased it.
|
|
1373
1373
|
import re as _re
|
|
1374
1374
|
stripped = _re.sub(r"[\W_]+", " ", str(text or "")).strip().lower()
|
package/src/events_bus.py
CHANGED
|
@@ -101,11 +101,10 @@ def emit(
|
|
|
101
101
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
102
|
_rotate_if_needed(path)
|
|
103
103
|
|
|
104
|
-
# v0.32.5 —
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
# `_next_id` se llama DENTRO del lock, garantizando monotonía.
|
|
104
|
+
# v0.32.5 — `_next_id(path)` used to run BEFORE taking the flock. Two
|
|
105
|
+
# concurrent emitters could read the same tail, compute the same id, and
|
|
106
|
+
# write two events with that id. Renderer dedup then dropped the second
|
|
107
|
+
# event silently. Calling `_next_id` INSIDE the lock guarantees monotonicity.
|
|
109
108
|
line = None
|
|
110
109
|
event = None
|
|
111
110
|
with path.open("a", encoding="utf-8") as fh:
|