1"""The worker-report lane: worker events at rest, and the verbatim relay.
3Proposal V1 §5 (the ruled test case). A plan worker that reaches AWAITING GATE
4or Blocked writes ONE event into ``comms/lanes/worker-reports/`` and stops
5there: it holds no credential, knows nothing about connectors, and touches no
6other lane. This module is the other half — the orchestrator-side reader and
7the grant-checked, egress-filtered relay that puts the worker's own words on
10**This module is the schema authority for the lane.** The writer lives in
11``oc_chat/oc_chat/scripts/plan_status.py`` (a ``report`` action on the existing
12worker channel) and deliberately does NOT import this package — that is what
13keeps workers credential-free. The two sides therefore agree by a written
14contract rather than by a shared import, and the contract is
15:class:`WorkerReportEvent`::
18 "schema_version": "1.0",
19 "event_id": "<26-character ULID, time-first so lexical == arrival>",
20 "lane": "worker-reports",
21 "direction": "inbound",
23 "plan_id": "231-worker-report-lane",
25 "status": "awaiting_gate" | "blocked",
26 "worktree": "<absolute worktree path>",
27 "report_path": "<absolute path of report.md in that worktree>",
28 "created_at_utc": "2026-09-01T12:00:00Z",
29 "summary": "<the worker's FULL closing summary, verbatim>",
30 "summary_sha256": "<sha256 of summary, utf-8>",
31 "relayed_at_utc": null,
32 "relay_chunk_count": null
35File naming follows the §3 bus convention, ``<utc-stamp>_worker_<ulid>.json``.
37Two properties are worth stating because they are easy to lose in a refactor:
39* **The egress scan runs on the WHOLE summary, before any chunking.** Scanning
40 the rendered chunks instead would let a credential that straddles a chunk
41 boundary leave in two safe-looking halves. The relay therefore blocks on the
42 joined text and never enqueues anything for an event that failed the scan.
43* **Chunk identity is derived from the event id**, so a relay interrupted after
44 chunk 3 of 5 resumes without re-sending chunks 1-3: the durable outbox
45 already holds them under the same idempotency keys and returns their existing
46 receipts instead of re-delivering.
50- oc_connect.audit (AuditStore, content_hash, timestamp, utc_now - audits relay outcomes, verifies summary_sha256 and stamps relayed_at_utc)
51- oc_connect.delivery (OutboundSender - the grant-checked send path every relayed chunk crosses)
52- oc_connect.egress (require_safe, EgressBlockedError - scans the whole summary before any chunking)
53- oc_diagnostics (_dbg structured logger)
54- option_c_connector_contracts (DeliveryReceipt, lane - chunk receipts and the lane's declared direction and risk ceiling)
55- pydantic (BaseModel, ConfigDict, field_validator - the WorkerReportEvent schema)
57from __future__
import annotations
62import secrets
as _secrets
63from collections.abc
import Callable
64from dataclasses
import dataclass
65from datetime
import datetime
66from pathlib
import Path
67from typing
import Literal
69from oc_diagnostics
import _dbg
70from option_c_connector_contracts
import DeliveryReceipt, lane
71from pydantic
import BaseModel, ConfigDict, field_validator
73from .audit
import AuditStore, content_hash, timestamp, utc_now
74from .delivery
import OutboundSender
75from .egress
import EgressBlockedError, require_safe
78WORKER_REPORT_LANE =
"worker-reports"
81WORKER_REPORT_SOURCE =
"worker"
83WORKER_REPORT_SCHEMA_VERSION =
"1.0"
87TELEGRAM_MESSAGE_LIMIT = 4096
92WorkerReportStatus = Literal[
"awaiting_gate",
"blocked"]
98WORKER_REPORT_BEGIN =
"<<<BEGIN WORKER REPORT {event_id}>>>"
99WORKER_REPORT_END =
"<<<END WORKER REPORT {event_id}>>>"
103NEUTRALISED_MARKER =
"<< <"
107 """A worker-report event is unreadable, malformed, or already consumed."""
111 """The relay could not put one event's summary on the transport."""
115 """One worker report at rest in ``lanes/worker-reports/``."""
117 model_config = ConfigDict(frozen=
True, extra=
"forbid", strict=
True)
121 lane: Literal[
"worker-reports"]
122 direction: Literal[
"inbound"]
123 risk_class: Literal[
"low"]
126 status: WorkerReportStatus
132 relayed_at_utc: str |
None =
None
133 relay_chunk_count: int |
None =
None
147 if not value.strip():
148 raise ValueError(
"must not be blank")
151 @field_validator("summary_sha256")
154 if len(value) != 64
or not all(c
in "0123456789abcdef" for c
in value):
155 raise ValueError(
"summary_sha256 must be a lowercase sha-256 digest")
160 """True once every chunk of this summary reached the transport."""
164 """True when the stored digest still describes the stored summary.
166 A mismatch means the file was edited after the worker wrote it. The
167 relay refuses such an event rather than forwarding text nobody
168 authored, which is why this is a question and not an assertion.
170 return content_hash(self.
summary) == self.summary_sha256
174 """Return the ordering marker prepended to one chunk of a split summary."""
175 return f
"[{index}/{total}]\n"
181 limit: int = TELEGRAM_MESSAGE_LIMIT,
183 """Split *summary* into pieces that fit *limit* once numbered.
185 The split is a plain slice, never a word-boundary or whitespace-trimming
186 split: ``"".join(chunk_summary(text)) == text`` is the property the whole
187 verbatim guarantee rests on. A summary that already fits comes back as one
188 piece, and :func:`render_chunk` leaves that single piece unnumbered.
190 The chunk count and the marker width depend on each other, so the budget is
191 solved to a fixed point rather than guessed: every marker is rendered at the
192 widest form (``[{total}/{total}]``) so all pieces share one budget.
194 if not isinstance(summary, str):
195 raise TypeError(
"a worker report summary must be text")
198 if limit < MIN_CHUNK_LIMIT:
199 raise ValueError(f
"chunk limit must be at least {MIN_CHUNK_LIMIT} characters")
200 if len(summary) <= limit:
203 for _
in range(len(summary)):
205 needed = math.ceil(len(summary) / budget)
213 summary[start : start + budget]
214 for start
in range(0, len(summary), budget)
219 """Return the message text for one chunk; a lone chunk is unnumbered."""
220 if total < 1
or not 1 <= index <= total:
221 raise ValueError(
"chunk index must fall inside the chunk count")
222 return piece
if total == 1
else f
"{chunk_prefix(index, total)}{piece}"
226 """Return the only representation in which a summary may be displayed.
228 The body is fenced with the event's own id and any literal ``<<<`` inside
229 it is neutralised, so a summary cannot forge its own closing fence. The
230 rendered copy is the only thing that changes; the stored text does not.
232 state =
"relayed" if event.is_relayed
else "unrelayed"
234 f
"[{state}] {event.event_id} {event.created_at_utc} "
235 f
"lane={event.lane} plan={event.plan_id} agent={event.agent} "
236 f
"status={event.status}"
238 body = event.summary.replace(
"<<<", NEUTRALISED_MARKER)
239 begin = WORKER_REPORT_BEGIN.format(event_id=event.event_id)
240 end = WORKER_REPORT_END.format(event_id=event.event_id)
241 return f
"{header}\nreport={event.report_path}\n{begin}\n{body}\n{end}"
245 path.parent.mkdir(parents=
True, exist_ok=
True)
246 temporary = path.with_name(f
".tmp-{path.name}-{_secrets.token_hex(8)}")
248 with temporary.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
249 handle.write(payload)
252 os.fsync(handle.fileno())
253 os.replace(temporary, path)
255 if temporary.exists():
263 """Read-and-mark access to one ``lanes/worker-reports/`` directory.
265 Lane separation is physical: this store is handed the lane directory and
266 has no path arithmetic that could reach ``inbound/`` or ``outbound/``.
271 directory: str | Path,
273 now: Callable[[], datetime] = utc_now,
278 def paths(self) -> tuple[Path, ...]:
279 """Return event files in arrival (filename) order, ignoring dotfiles."""
285 for path
in self.
directory.glob(
"*.json")
286 if path.is_file()
and not path.name.startswith(
".")
290 def _read(self, path: Path) -> WorkerReportEvent:
292 return WorkerReportEvent.model_validate_json(
293 path.read_text(encoding=
"utf-8")
295 except (OSError, UnicodeDecodeError, ValueError)
as exc:
297 f
"could not parse worker report event at {path} "
298 f
"({type(exc).__name__})"
301 def list_events(self, *, relayed: bool |
None =
None) -> tuple[WorkerReportEvent, ...]:
302 """Return stored events in arrival order, optionally by relay state."""
303 events = tuple(self.
_read(path)
for path
in self.
paths())
306 return tuple(event
for event
in events
if event.is_relayed
is relayed)
309 """Return the file holding one event or fail with its public id."""
310 suffix = f
"_{event_id}.json"
311 for path
in self.
paths():
312 if path.name.endswith(suffix):
315 f
"worker report event {event_id!r} is not in the lane"
318 def get(self, event_id: str) -> WorkerReportEvent:
319 """Return one stored event or fail with its public id."""
322 def mark_relayed(self, event_id: str, *, chunk_count: int) -> WorkerReportEvent:
323 """Record that one event's summary reached the transport in full."""
325 raise ValueError(
"a relayed event must have sent at least one chunk")
327 event = self.
_read(path)
330 f
"worker report event {event_id!r} was already relayed at "
331 f
"{event.relayed_at_utc}"
333 updated = event.model_copy(
335 "relayed_at_utc": timestamp(self.
_now()),
336 "relay_chunk_count": chunk_count,
341 json.dumps(updated.model_dump(mode=
"json"), indent=2, sort_keys=
True),
344 "OC_CONNECT.WORKER_REPORTS",
346 f
"event={event_id} plan={event.plan_id} chunks={chunk_count}",
352@dataclass(frozen=True)
354 """What the relay did with one event, in terms a CLI can print."""
359 status: WorkerReportStatus
361 audit_ids: tuple[str, ...]
365 """Forward unrelayed worker summaries verbatim through the standard send.
367 Every message still crosses the plan-219 pipeline — grant check, egress
368 scan, durable outbox, audit — because the relay delegates to
369 :class:`~oc_connect.delivery.OutboundSender` rather than reimplementing it.
370 What the relay adds is the whole-summary scan, the ordering markers, and
371 the lane's own audit line.
377 store: WorkerReportStore,
378 sender: OutboundSender,
383 risk_class: str |
None =
None,
384 limit: int = TELEGRAM_MESSAGE_LIMIT,
385 now: Callable[[], datetime] = utc_now,
399 event: WorkerReportEvent,
403 error_code: str |
None =
None,
405 descriptor = lane(WORKER_REPORT_LANE)
407 connector=
"telegram",
411 digest=event.summary_sha256,
412 idempotency_key=event.event_id,
414 error_code=error_code,
415 lane=WORKER_REPORT_LANE,
416 direction=descriptor.direction,
417 risk_class=descriptor.risk_ceiling,
420 def relay_one(self, event: WorkerReportEvent) -> RelayOutcome:
421 """Relay one event's full summary, or leave it unrelayed and raise."""
423 if not event.digest_matches():
425 f
"worker report event {event.event_id!r} does not match its own "
426 "summary digest; the file was modified after it was written"
429 require_safe(event.summary)
430 except EgressBlockedError:
431 self.
_audit(event,
"blocked", attempts=0, error_code=
"egress_finding")
433 "OC_CONNECT.WORKER_REPORTS",
435 f
"SECURITY_ERROR: blocked worker report event={event.event_id} "
436 f
"plan={event.plan_id}",
443 receipts: list[DeliveryReceipt] = []
444 for index, piece
in enumerate(pieces, start=1):
445 receipt = self.
sender.send(
451 idempotency_key=f
"{event.event_id}-{index:03d}",
453 receipts.append(receipt)
454 if receipt.outcome !=
"sent":
459 error_code=f
"chunk_{index}_of_{total}_{receipt.outcome}",
462 f
"worker report {event.event_id!r} stopped at chunk "
463 f
"{index}/{total} with outcome {receipt.outcome!r}; the event "
464 "stays unrelayed and re-running resumes without re-sending "
465 "the chunks already delivered"
467 self.
store.mark_relayed(event.event_id, chunk_count=total)
468 self.
_audit(event,
"sent", attempts=total)
470 "OC_CONNECT.WORKER_REPORTS",
472 f
"relayed event={event.event_id} plan={event.plan_id} "
473 f
"agent={event.agent} chunks={total}",
477 event_id=event.event_id,
478 plan_id=event.plan_id,
482 audit_ids=tuple(receipt.audit_id
for receipt
in receipts),
486 """Relay every unrelayed event, oldest first, stopping on the first refusal."""
489 for event
in self.
store.list_events(relayed=
False)
491 return tuple(outcomes)
496 "NEUTRALISED_MARKER",
497 "TELEGRAM_MESSAGE_LIMIT",
498 "WORKER_REPORT_BEGIN",
500 "WORKER_REPORT_LANE",
501 "WORKER_REPORT_SCHEMA_VERSION",
502 "WORKER_REPORT_SOURCE",
507 "WorkerReportRelayError",
508 "WorkerReportStatus",
str _required_text_is_nonblank(cls, str value)
bool digest_matches(self)
str _digest_matches_nothing_yet(cls, str value)
RelayOutcome relay_one(self, WorkerReportEvent event)
None __init__(self, *, WorkerReportStore store, OutboundSender sender, AuditStore audit, str grantee, str destination, str grant_id, str|None risk_class=None, int limit=TELEGRAM_MESSAGE_LIMIT, Callable[[], datetime] now=utc_now)
None _audit(self, WorkerReportEvent event, str outcome, *, int attempts, str|None error_code=None)
tuple[RelayOutcome,...] relay_pending(self)
Path path_for(self, str event_id)
WorkerReportEvent get(self, str event_id)
None __init__(self, str|Path directory, *, Callable[[], datetime] now=utc_now)
tuple[WorkerReportEvent,...] list_events(self, *, bool|None relayed=None)
WorkerReportEvent mark_relayed(self, str event_id, *, int chunk_count)
WorkerReportEvent _read(self, Path path)
tuple[Path,...] paths(self)
str render_event(WorkerReportEvent event)
None _atomic_write(Path path, str payload)
str chunk_prefix(int index, int total)
tuple[str,...] chunk_summary(str summary, *, int limit=TELEGRAM_MESSAGE_LIMIT)
str render_chunk(str piece, int index, int total)