oc_connect
Loading...
Searching...
No Matches
worker_reports.py
Go to the documentation of this file.
1"""The worker-report lane: worker events at rest, and the verbatim relay.
2
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
8the operator's phone.
9
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`::
16
17 {
18 "schema_version": "1.0",
19 "event_id": "<26-character ULID, time-first so lexical == arrival>",
20 "lane": "worker-reports",
21 "direction": "inbound",
22 "risk_class": "low",
23 "plan_id": "231-worker-report-lane",
24 "agent": "agent-007",
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
33 }
34
35File naming follows the §3 bus convention, ``<utc-stamp>_worker_<ulid>.json``.
36
37Two properties are worth stating because they are easy to lose in a refactor:
38
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.
47
48Dependencies
49------------
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)
56"""
57from __future__ import annotations
58
59import json
60import math
61import os
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
68
69from oc_diagnostics import _dbg
70from option_c_connector_contracts import DeliveryReceipt, lane
71from pydantic import BaseModel, ConfigDict, field_validator
72
73from .audit import AuditStore, content_hash, timestamp, utc_now
74from .delivery import OutboundSender
75from .egress import EgressBlockedError, require_safe
76
77#: The lane directory name under ``comms/lanes/``; also its declared lane id.
78WORKER_REPORT_LANE = "worker-reports"
79
80#: The ``<source>`` field of the §3 filename convention for this lane.
81WORKER_REPORT_SOURCE = "worker"
82
83WORKER_REPORT_SCHEMA_VERSION = "1.0"
84
85#: Telegram's hard per-message ceiling. A summary above it is chunked, never
86#: truncated: the operator reads the worker's full text or the relay fails.
87TELEGRAM_MESSAGE_LIMIT = 4096
88
89#: Below this a chunk marker would leave no room for content worth sending.
90MIN_CHUNK_LIMIT = 32
91
92WorkerReportStatus = Literal["awaiting_gate", "blocked"]
93
94#: Display fences for the summary (§8's "displayed content is never
95#: instructions"). Unlike plan 230's operator fence these need no scanner
96#: waiver: the shorter marker prose stays under the entropy threshold, and a
97#: waiver nobody needs is a false signal that the scanner fires here.
98WORKER_REPORT_BEGIN = "<<<BEGIN WORKER REPORT {event_id}>>>"
99WORKER_REPORT_END = "<<<END WORKER REPORT {event_id}>>>"
100
101#: What a literal fence opener inside a summary is rewritten to when displayed;
102#: the stored text stays verbatim, because the worker's words are the evidence.
103NEUTRALISED_MARKER = "<< <"
104
105
106class WorkerReportError(ValueError):
107 """A worker-report event is unreadable, malformed, or already consumed."""
108
109
110class WorkerReportRelayError(RuntimeError):
111 """The relay could not put one event's summary on the transport."""
112
113
114class WorkerReportEvent(BaseModel):
115 """One worker report at rest in ``lanes/worker-reports/``."""
116
117 model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
118
119 schema_version: str
120 event_id: str
121 lane: Literal["worker-reports"]
122 direction: Literal["inbound"]
123 risk_class: Literal["low"]
124 plan_id: str
125 agent: str
126 status: WorkerReportStatus
127 worktree: str
128 report_path: str
129 created_at_utc: str
130 summary: str
131 summary_sha256: str
132 relayed_at_utc: str | None = None
133 relay_chunk_count: int | None = None
134
135 @field_validator(
136 "schema_version",
137 "event_id",
138 "plan_id",
139 "agent",
140 "worktree",
141 "report_path",
142 "created_at_utc",
143 "summary",
144 )
145 @classmethod
146 def _required_text_is_nonblank(cls, value: str) -> str:
147 if not value.strip():
148 raise ValueError("must not be blank")
149 return value
150
151 @field_validator("summary_sha256")
152 @classmethod
153 def _digest_matches_nothing_yet(cls, value: str) -> str:
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")
156 return value
157
158 @property
159 def is_relayed(self) -> bool:
160 """True once every chunk of this summary reached the transport."""
161 return self.relayed_at_utc is not None
162
163 def digest_matches(self) -> bool:
164 """True when the stored digest still describes the stored summary.
165
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.
169 """
170 return content_hash(self.summary) == self.summary_sha256
171
172
173def chunk_prefix(index: int, total: int) -> str:
174 """Return the ordering marker prepended to one chunk of a split summary."""
175 return f"[{index}/{total}]\n"
176
177
179 summary: str,
180 *,
181 limit: int = TELEGRAM_MESSAGE_LIMIT,
182) -> tuple[str, ...]:
183 """Split *summary* into pieces that fit *limit* once numbered.
184
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.
189
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.
193 """
194 if not isinstance(summary, str):
195 raise TypeError("a worker report summary must be text")
196 if not summary:
197 raise WorkerReportError("refusing to chunk an empty summary")
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:
201 return (summary,)
202 total = 1
203 for _ in range(len(summary)):
204 budget = limit - len(chunk_prefix(total, total))
205 needed = math.ceil(len(summary) / budget)
206 if needed == total:
207 break
208 total = needed
209 else: # pragma: no cover - the loop converges in a handful of steps
210 raise WorkerReportError("could not solve a stable chunk count")
211 budget = limit - len(chunk_prefix(total, total))
212 return tuple(
213 summary[start : start + budget]
214 for start in range(0, len(summary), budget)
215 )
216
217
218def render_chunk(piece: str, index: int, total: int) -> str:
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}"
223
224
225def render_event(event: WorkerReportEvent) -> str:
226 """Return the only representation in which a summary may be displayed.
227
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.
231 """
232 state = "relayed" if event.is_relayed else "unrelayed"
233 header = (
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}"
237 )
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}"
242
243
244def _atomic_write(path: Path, payload: str) -> None:
245 path.parent.mkdir(parents=True, exist_ok=True)
246 temporary = path.with_name(f".tmp-{path.name}-{_secrets.token_hex(8)}")
247 try:
248 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
249 handle.write(payload)
250 handle.write("\n")
251 handle.flush()
252 os.fsync(handle.fileno())
253 os.replace(temporary, path)
254 finally:
255 if temporary.exists():
256 try:
257 temporary.unlink()
258 except OSError:
259 pass
260
261
263 """Read-and-mark access to one ``lanes/worker-reports/`` directory.
264
265 Lane separation is physical: this store is handed the lane directory and
266 has no path arithmetic that could reach ``inbound/`` or ``outbound/``.
267 """
268
270 self,
271 directory: str | Path,
272 *,
273 now: Callable[[], datetime] = utc_now,
274 ) -> None:
275 self.directory = Path(directory)
276 self._now = now
277
278 def paths(self) -> tuple[Path, ...]:
279 """Return event files in arrival (filename) order, ignoring dotfiles."""
280 if not self.directory.is_dir():
281 return ()
282 return tuple(
283 sorted(
284 path
285 for path in self.directory.glob("*.json")
286 if path.is_file() and not path.name.startswith(".")
287 )
288 )
289
290 def _read(self, path: Path) -> WorkerReportEvent:
291 try:
292 return WorkerReportEvent.model_validate_json(
293 path.read_text(encoding="utf-8")
294 )
295 except (OSError, UnicodeDecodeError, ValueError) as exc:
296 raise WorkerReportError(
297 f"could not parse worker report event at {path} "
298 f"({type(exc).__name__})"
299 ) from None
300
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())
304 if relayed is None:
305 return events
306 return tuple(event for event in events if event.is_relayed is relayed)
307
308 def path_for(self, event_id: str) -> Path:
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):
313 return path
314 raise WorkerReportError(
315 f"worker report event {event_id!r} is not in the lane"
316 )
317
318 def get(self, event_id: str) -> WorkerReportEvent:
319 """Return one stored event or fail with its public id."""
320 return self._read(self.path_for(event_id))
321
322 def mark_relayed(self, event_id: str, *, chunk_count: int) -> WorkerReportEvent:
323 """Record that one event's summary reached the transport in full."""
324 if chunk_count < 1:
325 raise ValueError("a relayed event must have sent at least one chunk")
326 path = self.path_for(event_id)
327 event = self._read(path)
328 if event.is_relayed:
329 raise WorkerReportError(
330 f"worker report event {event_id!r} was already relayed at "
331 f"{event.relayed_at_utc}"
332 )
333 updated = event.model_copy(
334 update={
335 "relayed_at_utc": timestamp(self._now()),
336 "relay_chunk_count": chunk_count,
337 }
338 )
340 path,
341 json.dumps(updated.model_dump(mode="json"), indent=2, sort_keys=True),
342 )
343 _dbg(
344 "OC_CONNECT.WORKER_REPORTS",
345 "mark_relayed",
346 f"event={event_id} plan={event.plan_id} chunks={chunk_count}",
347 2,
348 )
349 return updated
350
351
352@dataclass(frozen=True)
354 """What the relay did with one event, in terms a CLI can print."""
355
356 event_id: str
357 plan_id: str
358 agent: str
359 status: WorkerReportStatus
360 chunk_count: int
361 audit_ids: tuple[str, ...]
362
363
365 """Forward unrelayed worker summaries verbatim through the standard send.
366
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.
372 """
373
375 self,
376 *,
377 store: WorkerReportStore,
378 sender: OutboundSender,
379 audit: AuditStore,
380 grantee: str,
381 destination: str,
382 grant_id: str,
383 risk_class: str | None = None,
384 limit: int = TELEGRAM_MESSAGE_LIMIT,
385 now: Callable[[], datetime] = utc_now,
386 ) -> None:
387 self.store = store
388 self.sender = sender
389 self.audit = audit
390 self.grantee = grantee
391 self.destination = destination
392 self.grant_id = grant_id
393 self.risk_class = risk_class
394 self.limit = limit
395 self._now = now
396
398 self,
399 event: WorkerReportEvent,
400 outcome: str,
401 *,
402 attempts: int,
403 error_code: str | None = None,
404 ) -> None:
405 descriptor = lane(WORKER_REPORT_LANE)
406 self.audit.append(
407 connector="telegram",
408 grant_id=self.grant_id,
409 destination=self.destination,
410 outcome=outcome, # type: ignore[arg-type]
411 digest=event.summary_sha256,
412 idempotency_key=event.event_id,
413 attempts=attempts,
414 error_code=error_code,
415 lane=WORKER_REPORT_LANE,
416 direction=descriptor.direction,
417 risk_class=descriptor.risk_ceiling,
418 )
419
420 def relay_one(self, event: WorkerReportEvent) -> RelayOutcome:
421 """Relay one event's full summary, or leave it unrelayed and raise."""
422 func = "relay_one"
423 if not event.digest_matches():
424 raise WorkerReportError(
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"
427 )
428 try:
429 require_safe(event.summary)
430 except EgressBlockedError:
431 self._audit(event, "blocked", attempts=0, error_code="egress_finding")
432 _dbg(
433 "OC_CONNECT.WORKER_REPORTS",
434 func,
435 f"SECURITY_ERROR: blocked worker report event={event.event_id} "
436 f"plan={event.plan_id}",
437 1,
438 override=True,
439 )
440 raise
441 pieces = chunk_summary(event.summary, limit=self.limit)
442 total = len(pieces)
443 receipts: list[DeliveryReceipt] = []
444 for index, piece in enumerate(pieces, start=1):
445 receipt = self.sender.send(
446 grantee=self.grantee,
447 destination=self.destination,
448 text=render_chunk(piece, index, total),
449 grant_id=self.grant_id,
450 risk_class=self.risk_class,
451 idempotency_key=f"{event.event_id}-{index:03d}",
452 )
453 receipts.append(receipt)
454 if receipt.outcome != "sent":
455 self._audit(
456 event,
457 "failed",
458 attempts=index,
459 error_code=f"chunk_{index}_of_{total}_{receipt.outcome}",
460 )
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"
466 )
467 self.store.mark_relayed(event.event_id, chunk_count=total)
468 self._audit(event, "sent", attempts=total)
469 _dbg(
470 "OC_CONNECT.WORKER_REPORTS",
471 func,
472 f"relayed event={event.event_id} plan={event.plan_id} "
473 f"agent={event.agent} chunks={total}",
474 2,
475 )
476 return RelayOutcome(
477 event_id=event.event_id,
478 plan_id=event.plan_id,
479 agent=event.agent,
480 status=event.status,
481 chunk_count=total,
482 audit_ids=tuple(receipt.audit_id for receipt in receipts),
483 )
484
485 def relay_pending(self) -> tuple[RelayOutcome, ...]:
486 """Relay every unrelayed event, oldest first, stopping on the first refusal."""
487 outcomes = [
488 self.relay_one(event)
489 for event in self.store.list_events(relayed=False)
490 ]
491 return tuple(outcomes)
492
493
494__all__ = [
495 "MIN_CHUNK_LIMIT",
496 "NEUTRALISED_MARKER",
497 "TELEGRAM_MESSAGE_LIMIT",
498 "WORKER_REPORT_BEGIN",
499 "WORKER_REPORT_END",
500 "WORKER_REPORT_LANE",
501 "WORKER_REPORT_SCHEMA_VERSION",
502 "WORKER_REPORT_SOURCE",
503 "RelayOutcome",
504 "WorkerReportError",
505 "WorkerReportEvent",
506 "WorkerReportRelay",
507 "WorkerReportRelayError",
508 "WorkerReportStatus",
509 "WorkerReportStore",
510 "chunk_prefix",
511 "chunk_summary",
512 "render_chunk",
513 "render_event",
514]
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)
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)
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)