|
|
@@ -204,7 +204,6 @@ class TaskCoordinator:
|
|
|
event=EventDraft(
|
|
|
"ledger_created",
|
|
|
{
|
|
|
- "mission": spec.objective,
|
|
|
"root_task_id": root_task_id,
|
|
|
},
|
|
|
),
|
|
|
@@ -214,11 +213,7 @@ class TaskCoordinator:
|
|
|
raise
|
|
|
await asyncio.sleep(_cas_backoff(attempt_number))
|
|
|
continue
|
|
|
- await self._emit(
|
|
|
- root_trace_id,
|
|
|
- "ledger_created",
|
|
|
- {"mission": spec.objective, "root_task_id": root_task_id},
|
|
|
- )
|
|
|
+ await self._emit(committed.event)
|
|
|
return committed.ledger
|
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
@@ -298,6 +293,7 @@ class TaskCoordinator:
|
|
|
)
|
|
|
event_payload: Dict[str, Any] = {}
|
|
|
committed_fresh = False
|
|
|
+ committed_event = None
|
|
|
async with self._lock(root_trace_id):
|
|
|
for attempt_number in range(8):
|
|
|
ledger = await self.task_store.load(root_trace_id)
|
|
|
@@ -312,27 +308,35 @@ class TaskCoordinator:
|
|
|
if command_id and command_id in ledger.idempotency_results:
|
|
|
return dict(ledger.idempotency_results[command_id])
|
|
|
|
|
|
+ before = ledger.to_dict()
|
|
|
result = mutator(ledger)
|
|
|
- event_payload = _plain(result)
|
|
|
+ domain_changed = ledger.to_dict() != before
|
|
|
+ event_payload = _event_entity_refs(ledger, event_type, result)
|
|
|
if command_id:
|
|
|
ledger.command_records[command_id] = CommandRecord(
|
|
|
command_id=command_id,
|
|
|
operation=event_type,
|
|
|
fingerprint=fingerprint,
|
|
|
- result_ref=event_payload,
|
|
|
+ result_ref=_plain(result),
|
|
|
committed_revision=ledger.revision + 1,
|
|
|
operation_id=operation_id,
|
|
|
)
|
|
|
+ if not domain_changed and not command_id:
|
|
|
+ return result
|
|
|
try:
|
|
|
- await self.task_store.commit(
|
|
|
+ committed = await self.task_store.commit(
|
|
|
ledger,
|
|
|
expected_revision=ledger.revision,
|
|
|
idempotency_key=command_id,
|
|
|
- event=EventDraft(
|
|
|
- event_type,
|
|
|
- event_payload,
|
|
|
- command_id=command_id,
|
|
|
- operation_id=operation_id,
|
|
|
+ event=(
|
|
|
+ EventDraft(
|
|
|
+ event_type,
|
|
|
+ event_payload,
|
|
|
+ command_id=command_id,
|
|
|
+ operation_id=operation_id,
|
|
|
+ )
|
|
|
+ if domain_changed
|
|
|
+ else None
|
|
|
),
|
|
|
)
|
|
|
except RevisionConflict:
|
|
|
@@ -340,18 +344,19 @@ class TaskCoordinator:
|
|
|
raise
|
|
|
await asyncio.sleep(_cas_backoff(attempt_number))
|
|
|
continue
|
|
|
- committed_fresh = True
|
|
|
+ committed_fresh = domain_changed
|
|
|
+ committed_event = committed.event
|
|
|
break
|
|
|
else:
|
|
|
raise AssertionError("unreachable")
|
|
|
if committed_fresh:
|
|
|
- await self._emit(root_trace_id, event_type, event_payload)
|
|
|
+ await self._emit(committed_event)
|
|
|
return result
|
|
|
|
|
|
- async def _emit(self, root_trace_id: str, event_type: str, payload: Dict[str, Any]) -> None:
|
|
|
- if self.event_sink:
|
|
|
+ async def _emit(self, event: Any) -> None:
|
|
|
+ if self.event_sink and event is not None:
|
|
|
try:
|
|
|
- await self.event_sink.emit(root_trace_id, event_type, payload)
|
|
|
+ await self.event_sink.emit(event)
|
|
|
except Exception as exc:
|
|
|
# Events are an observational projection. Once the Ledger
|
|
|
# commit succeeds, an EventSink outage must not make callers
|
|
|
@@ -359,8 +364,8 @@ class TaskCoordinator:
|
|
|
logger.warning(
|
|
|
"Orchestration event emission failed after Ledger commit "
|
|
|
"(%s, %s): %s",
|
|
|
- root_trace_id,
|
|
|
- event_type,
|
|
|
+ event.root_trace_id,
|
|
|
+ event.event_type,
|
|
|
exc,
|
|
|
)
|
|
|
|
|
|
@@ -1575,7 +1580,6 @@ class TaskCoordinator:
|
|
|
stage.started_at = utc_now()
|
|
|
stage.updated_at = stage.started_at
|
|
|
return {
|
|
|
- "operation_id": operation_id,
|
|
|
"attempt_id": attempt_id,
|
|
|
"validation_id": validation_id,
|
|
|
}
|
|
|
@@ -1729,7 +1733,11 @@ class TaskCoordinator:
|
|
|
operation = ledger.operations[operation_id]
|
|
|
operation.validation_ids.append(validation.validation_id)
|
|
|
operation.updated_at = utc_now()
|
|
|
- return {"validation_id": validation.validation_id, "task_id": task_id}
|
|
|
+ return {
|
|
|
+ "validation_id": validation.validation_id,
|
|
|
+ "task_id": task_id,
|
|
|
+ "attempt_id": attempt_id,
|
|
|
+ }
|
|
|
|
|
|
result = await self._mutate(
|
|
|
root_trace_id,
|
|
|
@@ -2499,7 +2507,11 @@ class TaskCoordinator:
|
|
|
operation.attempt_ids = [attempt_id]
|
|
|
operation.validation_ids.append(report.validation_id)
|
|
|
operation.updated_at = utc_now()
|
|
|
- return {"validation_id": report.validation_id}
|
|
|
+ return {
|
|
|
+ "validation_id": report.validation_id,
|
|
|
+ "task_id": task_id,
|
|
|
+ "attempt_id": attempt_id,
|
|
|
+ }
|
|
|
|
|
|
result = await self._mutate(
|
|
|
root_trace_id,
|
|
|
@@ -2625,14 +2637,26 @@ class TaskCoordinator:
|
|
|
current, current_stage, operation_id, execution_epoch
|
|
|
)
|
|
|
):
|
|
|
- return {"recorded": False}
|
|
|
+ return {
|
|
|
+ "recorded": False,
|
|
|
+ "attempt_id": attempt_id,
|
|
|
+ "validation_id": validation_id,
|
|
|
+ }
|
|
|
if current_stage.execution_stats is not None:
|
|
|
if current_stage.execution_stats != stats:
|
|
|
raise TaskConflict("Execution stats were already recorded")
|
|
|
- return {"recorded": True}
|
|
|
+ return {
|
|
|
+ "recorded": True,
|
|
|
+ "attempt_id": attempt_id,
|
|
|
+ "validation_id": validation_id,
|
|
|
+ }
|
|
|
current_stage.execution_stats = stats
|
|
|
current_stage.updated_at = utc_now()
|
|
|
- return {"recorded": True}
|
|
|
+ return {
|
|
|
+ "recorded": True,
|
|
|
+ "attempt_id": attempt_id,
|
|
|
+ "validation_id": validation_id,
|
|
|
+ }
|
|
|
|
|
|
result = await self._mutate(
|
|
|
root_trace_id,
|
|
|
@@ -3161,6 +3185,39 @@ def _plain(value: Any) -> Any:
|
|
|
return json_values(value)
|
|
|
|
|
|
|
|
|
+def _event_entity_refs(
|
|
|
+ ledger: TaskLedger,
|
|
|
+ event_type: str,
|
|
|
+ result: Dict[str, Any],
|
|
|
+) -> Dict[str, Any]:
|
|
|
+ """Project a command result to schema-v2 entity references only."""
|
|
|
+
|
|
|
+ if event_type.startswith("operation_"):
|
|
|
+ return {}
|
|
|
+ if event_type == "tasks_created":
|
|
|
+ return {
|
|
|
+ "parent_task_id": result.get("parent_task_id"),
|
|
|
+ "task_ids": list(result.get("task_ids", [])),
|
|
|
+ }
|
|
|
+ if event_type == "ledger_created":
|
|
|
+ return {"root_task_id": result.get("root_task_id")}
|
|
|
+
|
|
|
+ refs: Dict[str, Any] = {}
|
|
|
+ for key in ("task_id", "attempt_id", "validation_id", "decision_id"):
|
|
|
+ value = result.get(key)
|
|
|
+ if value is not None:
|
|
|
+ refs[key] = value
|
|
|
+ validation_id = refs.get("validation_id")
|
|
|
+ if validation_id in ledger.validations:
|
|
|
+ validation = ledger.validations[validation_id]
|
|
|
+ refs.setdefault("task_id", validation.task_id)
|
|
|
+ refs.setdefault("attempt_id", validation.attempt_id)
|
|
|
+ attempt_id = refs.get("attempt_id")
|
|
|
+ if attempt_id in ledger.attempts:
|
|
|
+ refs.setdefault("task_id", ledger.attempts[attempt_id].task_id)
|
|
|
+ return refs
|
|
|
+
|
|
|
+
|
|
|
def _request_fingerprint(operation: str, payload: Any) -> str:
|
|
|
canonical = json.dumps(
|
|
|
{"operation": operation, "payload": _canonical_value(payload)},
|