From ff1e908c6f254d715b36b6129c80ad948cf235ed Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 13 May 2026 10:04:42 +0800 Subject: [PATCH] Close decision assistant H16 F17 G16 D18 --- SESSION.md | 21 +- TASKS.md | 26 +- .../operator/operator_workspace_service.py | 444 +++++++++++++++++- src/web/app.js | 25 + tests/unit/test_operator_workspace_service.py | 34 ++ 5 files changed, 533 insertions(+), 17 deletions(-) diff --git a/SESSION.md b/SESSION.md index 3ec3a52..4a8aa76 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,7 @@ ## Current Goal -Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, and concrete OKX announcements/system-status read-only sources. This run closed H15/F16/G15/D17 by adding handoff lifecycle / operator resume queue fields, digest proof follow-up queue and resolved states, template guardrail resolution / preferred-template decay fields, and setup-family promotion queue / demotion history hints. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host. +Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, and concrete OKX announcements/system-status read-only sources. This run closed H16/F17/G16/D18 by adding handoff resume persistence / resolved-lane state, digest proof follow-up resolution history / stale queue aging, template preference recovery / guardrail audit trails, and setup-family promotion decision history / review-lane aging. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host. ## Current Branch @@ -88,6 +88,10 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_digest_followup_slice: proof_escalation_followup_resolved_states - latest_decision_assistant_template_guardrail_resolution_slice: guardrail_resolution_preferred_decay - latest_decision_assistant_replay_promotion_queue_slice: setup_family_promotion_queue_demotion_history +- latest_decision_assistant_handoff_resume_persistence_slice: resume_queue_persistence_resolved_lanes +- latest_decision_assistant_digest_resolution_history_slice: proof_followup_resolution_history_queue_aging +- latest_decision_assistant_template_recovery_slice: preference_recovery_guardrail_audit +- latest_decision_assistant_replay_review_lane_slice: promotion_decision_history_review_lane_aging - latest_decision_assistant_summary_status: implemented - latest_decision_assistant_change_summary_status: implemented - latest_decision_assistant_evidence_status: implemented @@ -160,10 +164,14 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_digest_followup_status: implemented - latest_decision_assistant_template_guardrail_resolution_status: implemented - latest_decision_assistant_replay_promotion_queue_status: implemented +- latest_decision_assistant_handoff_resume_persistence_status: implemented +- latest_decision_assistant_digest_resolution_history_status: implemented +- latest_decision_assistant_template_recovery_status: implemented +- latest_decision_assistant_replay_review_lane_status: implemented - latest_decision_assistant_workbench_status: visible_local_smoke - latest_remote_decision_assistant_deploy_status: deployed_current_head - latest_remote_decision_assistant_smoke_status: passed -- latest_remote_decision_assistant_review_packet_status: handoff_lifecycle_proof_followup_guardrail_resolution_promotion_queue_visible +- latest_remote_decision_assistant_review_packet_status: resume_persistence_proof_resolution_template_recovery_review_lane_visible - latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup - latest_p14_slice: webhook_transport_retry - latest_p15_pxx_tasklist_status: ready @@ -242,6 +250,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. ## Completed In This Run +- Added handoff resume persistence / resolved-lane fields (`resume_persistence_key`, `resume_persistence_status`, `resolved_lane_state`, `resolved_lane_prompt`, `resume_queue_persistence_status`, and `resolved_lane_state_counts`) plus cockpit visibility. +- Added digest proof follow-up resolution history / stale queue aging (`proof_follow_up_age_band`, `proof_follow_up_stale_queue`, `proof_resolution_history`, `stale_proof_follow_up_count`, and `proof_follow_up_age_counts`). +- Added template preference recovery / guardrail audit fields (`preference_recovery_status`, `preference_recovery_prompt`, `guardrail_audit_trail`, `preference_recovery_queue`, and `guardrail_audit_trail_count`). +- Added setup-family promotion decision history / review-lane aging (`promotion_decision_history`, `promotion_decision_history_count`, `review_lane_age_status`, `review_lane_age_score`, and `review_lane_aging_hint`). +- Passed focused validation: `unittest tests.unit.test_operator_workspace_service`, `node --check src/web/app.js`, `unittest tests.unit.test_operator_workspace_service tests.unit.test_api_static_server tests.unit.test_api_server`, full `unittest discover -s tests/unit` (713 tests), and `scripts/run_script_static_checks.py`. +- Deployed the current working tree to `http://192.168.3.90:8000`; deploy smoke passed 11 endpoints, `run_remote_ops_smoke.py` passed 9 checks, and remote field smoke confirmed resume persistence, proof resolution history, template recovery/audit, and review-lane aging fields. - Added handoff lifecycle / operator resume queue fields (`lifecycle_state`, `resume_queue_rank`, `resume_action_label`, `handoff_lifecycle_summary`, `operator_resume_queue`, and `next_resume_*`) plus cockpit resume visibility. - Added digest proof follow-up queue and resolved-state fields (`proof_follow_up_state`, `proof_follow_up_action_label`, `proof_resolved_state`, `proof_follow_up_queue`, `resolved_proof_count`, and `proof_resolution_state_counts`). - Added template guardrail resolution / preferred-template decay fields (`guardrail_resolution_status`, `guardrail_resolution_prompt`, `preferred_template_decay`, `guardrail_resolution_queue`, and `preferred_template_decay_status`). @@ -531,11 +545,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - P24 deploy passed through `scripts/deploy_with_password.py` with 11 HTTP smoke endpoints. - P24 remote ops smoke passed on `http://192.168.3.90:8000` with 9 passed checks, warnings=[], blockers=[]. - PXX final validation passed: 709 unit tests, 73 script static checks, `node --check src/web/app.js`, P24 remote ops smoke passed, and closure gate passed. +- Latest H16/F17/G16/D18 validation passed: 713 unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote field smoke returned `status=passed`. - Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter. ## Exact Next Step -Continue the Decision Assistant dense operator workspace with the next bounded slice: H16 handoff resume queue persistence / resolved lane state, F17 digest proof follow-up resolution history / stale queue aging, G16 template preference recovery / guardrail audit trail, and D18 setup-family promotion decision history / review lane aging. PXX remains closed; do not create P25/P26 automatically, and do not enable real trading. +Continue the Decision Assistant dense operator workspace with the next bounded slice: H17 handoff resume history / stale persistence cleanup, F18 digest proof resolution SLA / repeated-stale proof suppression, G17 template recovery recommendations / audit filtering, and D19 setup-family review-lane SLA / promotion backlog prioritization. PXX remains closed; do not create P25/P26 automatically, and do not enable real trading. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index 6fc78cb..11f4fe2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -88,6 +88,10 @@ - [x] Decision Assistant F16 digest proof escalation follow-up queue / resolved proof states slice complete - [x] Decision Assistant G15 template guardrail resolution / preferred-template decay slice complete - [x] Decision Assistant D17 setup-family promotion queue / demotion history slice complete +- [x] Decision Assistant H16 handoff resume queue persistence / resolved lane state slice complete +- [x] Decision Assistant F17 digest proof follow-up resolution history / stale queue aging slice complete +- [x] Decision Assistant G16 template preference recovery / guardrail audit trail slice complete +- [x] Decision Assistant D18 setup-family promotion decision history / review lane aging slice complete ## Next Up @@ -107,10 +111,14 @@ - [x] F16 deepen digest recall with proof escalation follow-up queue / resolved proof states - [x] G15 continue review template ergonomics with template guardrail resolution / preferred-template decay - [x] D17 extend replay comparison with setup-family promotion queue / demotion history -- [ ] H16 continue compact operator workspace polish with handoff resume queue persistence / resolved lane state -- [ ] F17 deepen digest recall with proof follow-up resolution history / stale queue aging -- [ ] G16 continue review template ergonomics with template preference recovery / guardrail audit trail -- [ ] D18 extend replay comparison with setup-family promotion decision history / review lane aging +- [x] H16 continue compact operator workspace polish with handoff resume queue persistence / resolved lane state +- [x] F17 deepen digest recall with proof follow-up resolution history / stale queue aging +- [x] G16 continue review template ergonomics with template preference recovery / guardrail audit trail +- [x] D18 extend replay comparison with setup-family promotion decision history / review lane aging +- [ ] H17 continue compact operator workspace polish with handoff resume history / stale persistence cleanup +- [ ] F18 deepen digest recall with proof resolution SLA / repeated-stale proof suppression +- [ ] G17 continue review template ergonomics with template recovery recommendations / audit filtering +- [ ] D19 extend replay comparison with setup-family review-lane SLA / promotion backlog prioritization - [x] P8 local closure complete - [x] P9 local closure complete - [x] P10 local closure complete @@ -206,6 +214,10 @@ - [x] latest_decision_assistant_digest_followup_slice: proof_escalation_followup_resolved_states - [x] latest_decision_assistant_template_guardrail_resolution_slice: guardrail_resolution_preferred_decay - [x] latest_decision_assistant_replay_promotion_queue_slice: setup_family_promotion_queue_demotion_history +- [x] latest_decision_assistant_handoff_resume_persistence_slice: resume_queue_persistence_resolved_lanes +- [x] latest_decision_assistant_digest_resolution_history_slice: proof_followup_resolution_history_queue_aging +- [x] latest_decision_assistant_template_recovery_slice: preference_recovery_guardrail_audit +- [x] latest_decision_assistant_replay_review_lane_slice: promotion_decision_history_review_lane_aging - [x] latest_decision_assistant_summary_status: implemented - [x] latest_decision_assistant_change_summary_status: implemented - [x] latest_decision_assistant_evidence_status: implemented @@ -275,10 +287,14 @@ - [x] latest_decision_assistant_digest_followup_status: implemented - [x] latest_decision_assistant_template_guardrail_resolution_status: implemented - [x] latest_decision_assistant_replay_promotion_queue_status: implemented +- [x] latest_decision_assistant_handoff_resume_persistence_status: implemented +- [x] latest_decision_assistant_digest_resolution_history_status: implemented +- [x] latest_decision_assistant_template_recovery_status: implemented +- [x] latest_decision_assistant_replay_review_lane_status: implemented - [x] latest_decision_assistant_workbench_status: visible_local_smoke - [x] latest_remote_decision_assistant_deploy_status: deployed_current_head - [x] latest_remote_decision_assistant_smoke_status: passed -- [x] latest_remote_decision_assistant_review_packet_status: handoff_lifecycle_proof_followup_guardrail_resolution_promotion_queue_visible +- [x] latest_remote_decision_assistant_review_packet_status: resume_persistence_proof_resolution_template_recovery_review_lane_visible - [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup - [x] latest_p14_slice: webhook_transport_retry - [x] latest_p15_pxx_tasklist_status: ready diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 7f443ea..047edef 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -1090,6 +1090,10 @@ def _build_decision_assistant_review_entrypoints( item["guardrail_resolution_prompt"] = outcome.get("guardrail_resolution_prompt") item["preferred_template_decay"] = outcome.get("preferred_template_decay") item["preferred_template_decay_reason"] = outcome.get("preferred_template_decay_reason") + item["preference_recovery_status"] = outcome.get("preference_recovery_status") + item["preference_recovery_prompt"] = outcome.get("preference_recovery_prompt") + item["guardrail_audit_trail"] = outcome.get("guardrail_audit_trail") or [] + item["guardrail_audit_trail_count"] = outcome.get("guardrail_audit_trail_count") or 0 return { "status": "active", "note_count": len(recent_notes), @@ -1401,6 +1405,16 @@ def _score_template_outcome(*, template_id: str, usage: dict, notes: list[dict]) outcome_status=outcome_status, reuse_count=reuse_count, ) + preference_recovery_status = _template_preference_recovery_status( + negative_count=negative_count, + positive_count=positive_count, + actionable_count=action_count, + ) + guardrail_audit_trail = _template_guardrail_audit_trail( + template_id=template_id, + feedback_trend_history=feedback_trend_history, + negative_count=negative_count, + ) return { "template_id": template_id, "reuse_count": reuse_count, @@ -1435,6 +1449,13 @@ def _score_template_outcome(*, template_id: str, usage: dict, notes: list[dict]) positive_count=positive_count, actionable_count=action_count, ), + "preference_recovery_status": preference_recovery_status, + "preference_recovery_prompt": _template_preference_recovery_prompt( + template_id=template_id, + preference_recovery_status=preference_recovery_status, + ), + "guardrail_audit_trail": guardrail_audit_trail[:5], + "guardrail_audit_trail_count": len(guardrail_audit_trail), } @@ -1485,6 +1506,57 @@ def _template_preferred_decay_reason(*, negative_count: int, positive_count: int return "no_feedback_decay_signal" +def _template_preference_recovery_status(*, negative_count: int, positive_count: int, actionable_count: int) -> str: + if negative_count: + if positive_count + actionable_count >= negative_count: + return "recovery_in_progress" + return "recovery_required" + if positive_count or actionable_count: + return "recovered_or_stable" + return "awaiting_feedback" + + +def _template_preference_recovery_prompt(*, template_id: str, preference_recovery_status: str) -> str: + template_label = template_id.replace("_", " ") or "template" + if preference_recovery_status == "recovery_required": + return f"Collect a recovery note before {template_label} can be preferred again." + if preference_recovery_status == "recovery_in_progress": + return f"Review the latest useful feedback before restoring {template_label} preference." + if preference_recovery_status == "recovered_or_stable": + return f"{template_label} preference is stable; keep capturing outcome evidence after reuse." + return f"{template_label} still needs outcome feedback before preference can be scored." + + +def _template_guardrail_audit_trail(*, template_id: str, feedback_trend_history: list[dict], negative_count: int) -> list[dict]: + trail = [] + for index, item in enumerate(feedback_trend_history[:5], start=1): + signal = str(item.get("signal") or "reuse_only") + trail.append( + { + "template_id": template_id, + "event": "negative_guardrail" if signal == "negative" else "feedback_signal", + "signal": signal, + "label": item.get("label"), + "created_ts": item.get("created_ts"), + "audit_index": index, + "resolution_status": "resolution_required" if signal == "negative" else "evidence_captured", + } + ) + if negative_count and not any(item.get("event") == "negative_guardrail" for item in trail): + trail.append( + { + "template_id": template_id, + "event": "negative_guardrail", + "signal": "negative", + "label": "Negative feedback", + "created_ts": None, + "audit_index": len(trail) + 1, + "resolution_status": "resolution_required", + } + ) + return trail + + def _build_template_feedback_loop(*, template_id: str, outcome_status: str, reuse_count: int) -> dict: template_label = template_id.replace("_", " ") or "template" if outcome_status == "review_before_reuse": @@ -1566,6 +1638,22 @@ def _build_template_feedback_aggregation( for item in template_outcomes.values() if item.get("guardrail_resolution_status") == "resolution_required" ] + preference_recovery_queue = [ + { + "template_id": str(item.get("template_id") or ""), + "status": item.get("preference_recovery_status"), + "prompt": item.get("preference_recovery_prompt"), + "decay": item.get("preferred_template_decay"), + "guardrail_status": item.get("guardrail_resolution_status"), + } + for item in template_outcomes.values() + if item.get("preference_recovery_status") in {"recovery_required", "recovery_in_progress", "awaiting_feedback"} + ] + guardrail_audit_trail = [ + row + for item in template_outcomes.values() + for row in list(item.get("guardrail_audit_trail") or []) + ] rated_template_count = sum( 1 for item in template_outcomes.values() @@ -1622,6 +1710,13 @@ def _build_template_feedback_aggregation( "preferred_template_decay_max": max( [float(item.get("decay") or 0.0) for item in guardrail_resolution_queue] or [0.0] ), + "preference_recovery_queue": preference_recovery_queue[:5], + "preference_recovery_count": len(preference_recovery_queue), + "next_preference_recovery_template_id": ( + preference_recovery_queue[0].get("template_id") if preference_recovery_queue else None + ), + "guardrail_audit_trail": guardrail_audit_trail[:5], + "guardrail_audit_trail_count": len(guardrail_audit_trail), } @@ -1865,10 +1960,42 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic "prompt": group.get("proof_escalation_prompt"), "evidence_anchor": group.get("refresh_evidence_anchor"), "resolved_state": group.get("proof_resolved_state"), + "age_seconds": group.get("proof_follow_up_age_seconds"), + "age_band": group.get("proof_follow_up_age_band"), + "stale_queue": group.get("proof_follow_up_stale_queue"), } for group in groups if group.get("proof_follow_up_state") in {"follow_up_required", "watch"} ] + stale_proof_follow_ups = [ + group + for group in groups + if bool(group.get("proof_follow_up_stale_queue")) + ] + proof_follow_up_age_counts = {} + for group in groups: + age_band = str(group.get("proof_follow_up_age_band") or "unknown") + proof_follow_up_age_counts[age_band] = int(proof_follow_up_age_counts.get(age_band) or 0) + 1 + proof_resolution_history = [ + row + for group in groups + for row in list(group.get("proof_resolution_history") or []) + ] + proof_follow_up_with_age = [ + group + for group in groups + if group.get("proof_follow_up_state") in {"follow_up_required", "watch"} + and group.get("proof_follow_up_age_seconds") is not None + ] + oldest_proof_follow_up = dict( + sorted( + proof_follow_up_with_age, + key=lambda row: int(row.get("proof_follow_up_age_seconds") or 0), + reverse=True, + )[0] + if proof_follow_up_with_age + else {} + ) proof_resolution_state_counts = {} for group in groups: state = str(group.get("proof_resolved_state") or "unknown") @@ -1912,6 +2039,12 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic "resolved_proof_count": proof_resolution_state_counts.get("resolved_fresh", 0), "proof_resolution_state_counts": proof_resolution_state_counts, "next_proof_follow_up_prompt": proof_follow_up_queue[0].get("prompt") if proof_follow_up_queue else None, + "stale_proof_follow_up_count": len(stale_proof_follow_ups), + "proof_follow_up_age_counts": proof_follow_up_age_counts, + "proof_resolution_history": proof_resolution_history[:5], + "proof_resolution_history_count": len(proof_resolution_history), + "oldest_proof_follow_up_kind": oldest_proof_follow_up.get("kind"), + "oldest_proof_follow_up_age_seconds": oldest_proof_follow_up.get("proof_follow_up_age_seconds"), } @@ -1949,6 +2082,21 @@ def _build_decision_assistant_digest_history_groups(history: list[dict], *, reco freshness_proof_status=freshness_proof_status, refresh_evidence_anchor=refresh_evidence_anchor, ) + proof_follow_up_age_band = _digest_history_proof_follow_up_age_band( + age=age, + proof_follow_up_state=proof_follow_up_state, + ) + proof_follow_up_stale_queue = _digest_history_proof_follow_up_stale_queue( + proof_follow_up_state=proof_follow_up_state, + proof_age_trend=proof_age_trend, + proof_follow_up_age_band=proof_follow_up_age_band, + ) + proof_resolution_history = _digest_history_proof_resolution_history( + kind=kind, + proof_follow_up_state=proof_follow_up_state, + proof_resolved_state=proof_resolved_state, + refresh_confirmation_trail=refresh_confirmation_trail, + ) groups.append( { "kind": kind, @@ -1995,6 +2143,10 @@ def _build_decision_assistant_digest_history_groups(history: list[dict], *, reco proof_follow_up_state=proof_follow_up_state, ), "proof_resolved_state": proof_resolved_state, + "proof_follow_up_age_seconds": age.get("age_seconds"), + "proof_follow_up_age_band": proof_follow_up_age_band, + "proof_follow_up_stale_queue": proof_follow_up_stale_queue, + "proof_resolution_history": proof_resolution_history, "refresh_confirmation_trail": refresh_confirmation_trail, "latest_confirmation_trail_item": refresh_confirmation_trail[0] if refresh_confirmation_trail else None, "items": items[:3], @@ -2226,6 +2378,60 @@ def _digest_history_proof_resolved_state(*, proof_escalation_level: str, freshne return "unresolved" +def _digest_history_proof_follow_up_age_band(*, age: dict, proof_follow_up_state: str) -> str: + age_band = str(age.get("age_band") or "unknown_age") + if proof_follow_up_state == "resolved": + return "resolved" + if age_band in {"untimed", "unknown_age"}: + return "missing_age_proof" + if age_band == "stale": + return "stale_follow_up" + if age_band == "same_session": + return "aging_follow_up" + if age_band in {"fresh_now", "monitor_window"}: + return "current_follow_up" + return "unknown_follow_up_age" + + +def _digest_history_proof_follow_up_stale_queue( + *, + proof_follow_up_state: str, + proof_age_trend: str, + proof_follow_up_age_band: str, +) -> bool: + return ( + proof_follow_up_state == "follow_up_required" + and ( + proof_age_trend in {"stale", "missing"} + or proof_follow_up_age_band in {"stale_follow_up", "missing_age_proof"} + ) + ) + + +def _digest_history_proof_resolution_history( + *, + kind: str, + proof_follow_up_state: str, + proof_resolved_state: str, + refresh_confirmation_trail: list[dict], +) -> list[dict]: + history = [] + for index, item in enumerate(refresh_confirmation_trail[:3], start=1): + history.append( + { + "kind": kind, + "event": "proof_resolution", + "state": proof_resolved_state, + "follow_up_state": proof_follow_up_state, + "proof_label": item.get("proof_label"), + "ts": item.get("ts"), + "evidence_anchor": item.get("evidence_anchor"), + "history_index": index, + } + ) + return history + + def _digest_history_refresh_evidence_anchor(*, kind: str, items: list[dict], latest_ts) -> dict: latest = dict(items[0] or {}) if items else {} title = str(latest.get("title") or kind or "history") @@ -2540,6 +2746,16 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: "lifecycle_state": _scan_handoff_lifecycle_state(str(item.get("handoff_status") or "")), "resume_queue_rank": _scan_handoff_resume_rank(str(item.get("handoff_status") or "")), "resume_action_label": _scan_handoff_resume_action_label(str(item.get("handoff_status") or "")), + "resume_persistence_key": _scan_handoff_resume_persistence_key( + persistence_key=str(item.get("handoff_persistence_key") or ""), + handoff_marker=str(item.get("handoff_marker") or ""), + ), + "resume_persistence_status": _scan_handoff_resume_persistence_status(str(item.get("handoff_status") or "")), + "resolved_lane_state": _scan_handoff_resolved_lane_state(str(item.get("handoff_status") or "")), + "resolved_lane_prompt": _scan_handoff_resolved_lane_prompt( + title=str(item.get("title") or item.get("target_section") or "handoff"), + handoff_status=str(item.get("handoff_status") or ""), + ), } for item in cards if item.get("handoff_marker") @@ -2552,6 +2768,15 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: ] next_cleanup_marker = dict(cleanup_markers[0] or {}) if cleanup_markers else {} operator_resume_queue = _build_scan_handoff_resume_queue(markers) + resolved_lane_state_counts = _build_scan_handoff_resolved_lane_state_counts(markers) + next_unresolved_marker = next( + ( + item + for item in operator_resume_queue + if item.get("resolved_lane_state") not in {"resolved_ready", "resolved_carry_forward"} + ), + {}, + ) stale_after_values = [ int(item.get("stale_after_seconds") or 0) for item in markers @@ -2581,6 +2806,17 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: "next_resume_marker": operator_resume_queue[0].get("handoff_marker") if operator_resume_queue else None, "next_resume_action_label": operator_resume_queue[0].get("resume_action_label") if operator_resume_queue else None, "next_resume_prompt": operator_resume_queue[0].get("handoff_prompt") if operator_resume_queue else None, + "resume_queue_persistence_status": _scan_handoff_resume_queue_persistence_status(operator_resume_queue), + "resume_queue_persistence_key": operator_resume_queue[0].get("resume_persistence_key") if operator_resume_queue else None, + "resolved_lane_state_counts": resolved_lane_state_counts, + "resolved_lane_count": int(resolved_lane_state_counts.get("resolved_ready") or 0) + + int(resolved_lane_state_counts.get("resolved_carry_forward") or 0), + "unresolved_lane_count": sum( + int(count or 0) + for state, count in resolved_lane_state_counts.items() + if state not in {"resolved_ready", "resolved_carry_forward"} + ), + "next_resolved_lane_prompt": next_unresolved_marker.get("resolved_lane_prompt"), "handoff_stale_policy": "Persisted handoffs older than their stale window should be reopened and refreshed before reuse.", "handoff_cleanup_recommendation": _scan_handoff_cleanup_recommendation(markers), "snooze_options": [ @@ -2649,6 +2885,61 @@ def _scan_handoff_resume_action_label(handoff_status: str) -> str: return "Resume Ready" +def _scan_handoff_resume_persistence_key(*, persistence_key: str, handoff_marker: str) -> str: + base = str(persistence_key or handoff_marker or "handoff").strip() or "handoff" + return f"{base}:resume_state" + + +def _scan_handoff_resume_persistence_status(handoff_status: str) -> str: + if handoff_status == "next_handoff": + return "persist_next_resume" + if handoff_status in {"blocked_handoff", "refresh_handoff", "ready_handoff"}: + return "persist_for_resolution" + return "no_persistence_needed" + + +def _scan_handoff_resume_queue_persistence_status(operator_resume_queue: list[dict]) -> str: + if not operator_resume_queue: + return "idle" + if any(item.get("resume_persistence_status") == "persist_next_resume" for item in operator_resume_queue): + return "persist_next_resume" + if any(item.get("resume_persistence_status") == "persist_for_resolution" for item in operator_resume_queue): + return "persist_for_resolution" + return "no_persistence_needed" + + +def _scan_handoff_resolved_lane_state(handoff_status: str) -> str: + if handoff_status == "context_ready": + return "resolved_carry_forward" + if handoff_status == "ready_handoff": + return "resolved_ready" + if handoff_status == "blocked_handoff": + return "pending_blocker_resolution" + if handoff_status == "refresh_handoff": + return "pending_refresh_resolution" + return "pending_resume" + + +def _scan_handoff_resolved_lane_prompt(*, title: str, handoff_status: str) -> str: + if handoff_status == "context_ready": + return f"{title} is carried forward; keep the saved context available for the next cockpit pass." + if handoff_status == "ready_handoff": + return f"{title} is resolved enough to resume when the operator returns to this lane." + if handoff_status == "blocked_handoff": + return f"Resolve the blocker for {title} before the lane is treated as complete." + if handoff_status == "refresh_handoff": + return f"Refresh {title} before reusing the persisted handoff." + return f"Resume {title} from the persisted handoff before opening a new lane." + + +def _build_scan_handoff_resolved_lane_state_counts(markers: list[dict]) -> dict: + counts: dict[str, int] = {} + for item in markers: + state = str(item.get("resolved_lane_state") or "pending_resume") + counts[state] = int(counts.get(state) or 0) + 1 + return counts + + def _build_scan_handoff_resume_queue(markers: list[dict]) -> list[dict]: return sorted( [ @@ -2660,6 +2951,11 @@ def _build_scan_handoff_resume_queue(markers: list[dict]) -> list[dict]: "resume_action_label": item.get("resume_action_label"), "handoff_prompt": item.get("handoff_prompt"), "resume_queue_rank": item.get("resume_queue_rank"), + "persistence_key": item.get("persistence_key"), + "resume_persistence_key": item.get("resume_persistence_key"), + "resume_persistence_status": item.get("resume_persistence_status"), + "resolved_lane_state": item.get("resolved_lane_state"), + "resolved_lane_prompt": item.get("resolved_lane_prompt"), } for item in markers if item.get("lifecycle_state") != "carry_forward" @@ -3005,6 +3301,19 @@ def _build_setup_replay_broader_baseline_summary( "promotion_queue_rank": 5, "promotion_next_action": "Collect more replay samples before promotion review.", "promotion_queue_item": None, + "promotion_decision_history": [ + { + "event": "promotion_queue_decision", + "setup_family": setup_family, + "status": "collect_samples", + "promotion_ready": False, + "reason": "insufficient_samples", + } + ], + "promotion_decision_history_count": 1, + "review_lane_age_status": "aging_collect_samples", + "review_lane_age_score": 30, + "review_lane_aging_hint": f"{setup_family} review lane is aging until enough replay samples exist.", "demotion_history": [], "demotion_history_count": 0, "focus_recommendation": f"Collect samples before changing focus for {setup_family}.", @@ -3213,6 +3522,19 @@ def _build_setup_family_trend_delta( "promotion_queue_rank": 5, "promotion_next_action": "Collect more replay samples before promotion review.", "promotion_queue_item": None, + "promotion_decision_history": [ + { + "event": "promotion_queue_decision", + "setup_family": setup_family, + "status": "collect_samples", + "promotion_ready": False, + "reason": "insufficient_samples", + } + ], + "promotion_decision_history_count": 1, + "review_lane_age_status": "aging_collect_samples", + "review_lane_age_score": 30, + "review_lane_aging_hint": f"{setup_family} review lane is aging until enough replay samples exist.", "demotion_history": [], "demotion_history_count": 0, "focus_recommendation": f"Collect samples before changing focus for {setup_family}.", @@ -3299,6 +3621,35 @@ def _build_setup_family_trend_delta( focus_reasons=focus_reasons, trend_status=trend_status, ) + promotion_queue_rank = _setup_family_promotion_queue_rank(promotion_queue_status=promotion_queue_status) + promotion_next_action = _setup_family_promotion_next_action( + setup_family=setup_family, + promotion_queue_status=promotion_queue_status, + ) + promotion_queue_item = _setup_family_promotion_queue_item( + setup_family=setup_family, + promotion_queue_status=promotion_queue_status, + promotion_ready=promotion_ready, + watchlist_priority_score=watchlist_priority_score, + ) + promotion_decision_history = _setup_family_promotion_decision_history( + setup_family=setup_family, + promotion_queue_status=promotion_queue_status, + promotion_blockers=promotion_blockers, + demotion_history=demotion_history, + promotion_ready=promotion_ready, + ) + review_lane_age_status = _setup_family_review_lane_age_status( + promotion_queue_status=promotion_queue_status, + sample_gap=sample_gap, + drift_alert=drift_alert, + trend_status=trend_status, + ) + review_lane_age_score = _setup_family_review_lane_age_score( + watchlist_priority_score=watchlist_priority_score, + sample_gap=sample_gap, + drift_alert=drift_alert, + ) return { "status": "active", "trend_status": trend_status, @@ -3326,16 +3677,16 @@ def _build_setup_family_trend_delta( ), "promotion_blockers": promotion_blockers, "promotion_queue_status": promotion_queue_status, - "promotion_queue_rank": _setup_family_promotion_queue_rank(promotion_queue_status=promotion_queue_status), - "promotion_next_action": _setup_family_promotion_next_action( + "promotion_queue_rank": promotion_queue_rank, + "promotion_next_action": promotion_next_action, + "promotion_queue_item": promotion_queue_item, + "promotion_decision_history": promotion_decision_history[:6], + "promotion_decision_history_count": len(promotion_decision_history), + "review_lane_age_status": review_lane_age_status, + "review_lane_age_score": review_lane_age_score, + "review_lane_aging_hint": _setup_family_review_lane_aging_hint( setup_family=setup_family, - promotion_queue_status=promotion_queue_status, - ), - "promotion_queue_item": _setup_family_promotion_queue_item( - setup_family=setup_family, - promotion_queue_status=promotion_queue_status, - promotion_ready=promotion_ready, - watchlist_priority_score=watchlist_priority_score, + review_lane_age_status=review_lane_age_status, ), "demotion_history": demotion_history[:5], "demotion_history_count": len(demotion_history), @@ -3477,6 +3828,81 @@ def _setup_family_demotion_history( return history +def _setup_family_promotion_decision_history( + *, + setup_family: str, + promotion_queue_status: str, + promotion_blockers: list[str], + demotion_history: list[dict], + promotion_ready: bool, +) -> list[dict]: + history = [ + { + "event": "promotion_queue_decision", + "setup_family": setup_family, + "status": promotion_queue_status, + "promotion_ready": promotion_ready, + "reason": "operator_review_ready" if promotion_ready else (promotion_blockers[0] if promotion_blockers else "watch_lane"), + } + ] + for blocker in promotion_blockers[:3]: + history.append( + { + "event": "promotion_blocker_reviewed", + "setup_family": setup_family, + "status": promotion_queue_status, + "reason": blocker, + } + ) + for item in demotion_history[:3]: + history.append( + { + "event": item.get("event") or "demotion_history", + "setup_family": setup_family, + "status": promotion_queue_status, + "reason": item.get("reason") or item.get("severity") or "review_lane_history", + } + ) + return history + + +def _setup_family_review_lane_age_status( + *, + promotion_queue_status: str, + sample_gap: int, + drift_alert: bool, + trend_status: str, +) -> str: + if drift_alert or promotion_queue_status == "demotion_review": + return "stale_demotion_review" + if sample_gap > 0: + return "aging_collect_samples" + if promotion_queue_status == "ready_for_operator_review": + return "ready_now" + if trend_status == "mixed_vs_family_peers": + return "aging_watch" + return "watching" + + +def _setup_family_review_lane_age_score(*, watchlist_priority_score: int, sample_gap: int, drift_alert: bool) -> int: + score = int(watchlist_priority_score or 0) + min(20, int(sample_gap or 0) * 4) + if drift_alert: + score += 10 + return max(0, min(100, score)) + + +def _setup_family_review_lane_aging_hint(*, setup_family: str, review_lane_age_status: str) -> str: + if review_lane_age_status == "ready_now": + return f"{setup_family} is ready for analysis-lane promotion review; execution permissions stay unchanged." + if review_lane_age_status == "stale_demotion_review": + return f"{setup_family} needs demotion review before it remains in the primary analysis lane." + if review_lane_age_status == "aging_collect_samples": + return f"{setup_family} review lane is aging until enough replay samples exist." + if review_lane_age_status == "aging_watch": + return f"{setup_family} remains in an aging watch lane until mixed deltas resolve." + return f"{setup_family} remains in watch mode while review-lane evidence accumulates." + + def _rank_session_composite(rows: list[dict], *, session_code: str) -> int | None: if not session_code: return None diff --git a/src/web/app.js b/src/web/app.js index 3c1596b..fd81250 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -576,6 +576,12 @@ function renderDecisionAssistant(summary) { ['handoff lifecycle', formatJsonInline(scanHandoffSummary.handoff_lifecycle_summary?.state_counts || {})], ['resume queue', String(scanHandoffSummary.resume_queue_count ?? 0)], ['resume queue items', formatList((scanHandoffSummary.operator_resume_queue || []).map((item) => `${item.resume_action_label || 'Resume'}: ${item.title || item.target_section}`))], + ['resume persistence', scanHandoffSummary.resume_queue_persistence_status || 'n/a'], + ['resume persistence key', scanHandoffSummary.resume_queue_persistence_key || 'n/a'], + ['resolved lane states', formatJsonInline(scanHandoffSummary.resolved_lane_state_counts || {})], + ['resolved lanes', String(scanHandoffSummary.resolved_lane_count ?? 0)], + ['unresolved lanes', String(scanHandoffSummary.unresolved_lane_count ?? 0)], + ['next resolved lane prompt', scanHandoffSummary.next_resolved_lane_prompt || 'n/a'], ['next resume action', scanHandoffSummary.next_resume_action_label || 'n/a'], ['next resume prompt', scanHandoffSummary.next_resume_prompt || 'n/a'], ['handoff cleanup recommendation', scanHandoffSummary.handoff_cleanup_recommendation || 'n/a'], @@ -705,8 +711,13 @@ function renderDecisionAssistant(summary) { ['next stale proof kind', digestSurface.history_summary?.next_stale_proof_kind || 'n/a'], ['next stale proof prompt', digestSurface.history_summary?.next_stale_proof_prompt || 'n/a'], ['proof follow-up queue', String(digestSurface.history_summary?.proof_follow_up_queue_count ?? 0)], + ['stale proof follow-ups', String(digestSurface.history_summary?.stale_proof_follow_up_count ?? 0)], + ['proof follow-up age', formatJsonInline(digestSurface.history_summary?.proof_follow_up_age_counts || {})], + ['oldest proof follow-up', digestSurface.history_summary?.oldest_proof_follow_up_kind || 'n/a'], + ['oldest proof age', formatDurationSeconds(digestSurface.history_summary?.oldest_proof_follow_up_age_seconds)], ['resolved proofs', String(digestSurface.history_summary?.resolved_proof_count ?? 0)], ['proof resolution states', formatJsonInline(digestSurface.history_summary?.proof_resolution_state_counts || {})], + ['proof resolution history', formatList((digestSurface.history_summary?.proof_resolution_history || []).map((item) => `${item.kind}:${item.state}`))], ['next proof follow-up', digestSurface.history_summary?.next_proof_follow_up_prompt || 'n/a'], ['confirmation trail items', String(digestSurface.history_summary?.refresh_confirmation_trail_count ?? 0)], ['confirmation trail', formatList((digestSurface.history_summary?.refresh_confirmation_trail || []).map((item) => `${item.kind}:${item.proof_label}`))], @@ -798,6 +809,9 @@ function renderDecisionAssistant(summary) { ['proof follow-up', item.proof_follow_up_state || 'n/a'], ['proof follow-up action', item.proof_follow_up_action_label || 'n/a'], ['proof resolved state', item.proof_resolved_state || 'n/a'], + ['proof follow-up age', item.proof_follow_up_age_band || 'n/a'], + ['proof stale queue', String(Boolean(item.proof_follow_up_stale_queue))], + ['proof resolution history', formatList((item.proof_resolution_history || []).map((row) => `${row.proof_label || row.state}: ${formatTimestamp(row.ts)}`))], ['latest proof trail', item.latest_confirmation_trail_item?.proof_label || 'n/a'], ['proof trail items', formatList((item.refresh_confirmation_trail || []).map((row) => `${row.title || row.kind}: ${row.proof_label}`))], ['refresh evidence', item.refresh_evidence_anchor || 'n/a'], @@ -857,6 +871,9 @@ function renderDecisionAssistant(summary) { ['negative guardrails', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.negative_feedback_guardrail_count ?? 0)], ['guardrail templates', formatList(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.negative_feedback_guardrail_template_ids || [])], ['guardrail resolution queue', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.guardrail_resolution_count ?? 0)], + ['preference recovery queue', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preference_recovery_count ?? 0)], + ['next preference recovery', reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.next_preference_recovery_template_id || 'n/a'], + ['guardrail audit trail', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.guardrail_audit_trail_count ?? 0)], ['preferred template decay', reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preferred_template_decay_status || 'n/a'], ['preferred decay templates', formatList(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preferred_template_decay_template_ids || [])], ['max preference decay', formatPercent(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preferred_template_decay_max)], @@ -919,6 +936,9 @@ function renderDecisionAssistant(summary) { ['guardrail resolution prompt', item.guardrail_resolution_prompt || 'n/a'], ['preference decay', formatPercent(item.preferred_template_decay)], ['decay reason', item.preferred_template_decay_reason || 'n/a'], + ['preference recovery', item.preference_recovery_status || 'n/a'], + ['recovery prompt', item.preference_recovery_prompt || 'n/a'], + ['guardrail audit trail', formatList((item.guardrail_audit_trail || []).map((row) => `${row.label || row.signal}: ${row.resolution_status}`))], ['feedback trend history', formatList((item.feedback_trend_history || []).map((row) => `${row.label || row.signal}: ${formatTimestamp(row.created_ts)}`))], ], [ @@ -1221,6 +1241,11 @@ function renderDecisionAssistant(summary) { ['promotion queue status', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_queue_status || 'n/a'], ['promotion queue rank', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_queue_rank ?? 'n/a')], ['promotion next action', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_next_action || 'n/a'], + ['promotion decision history', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_decision_history_count ?? 0)], + ['promotion decision reasons', formatList((setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_decision_history || []).map((item) => item.reason || item.event))], + ['review lane age', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.review_lane_age_status || 'n/a'], + ['review lane age score', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.review_lane_age_score ?? 'n/a')], + ['review lane aging hint', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.review_lane_aging_hint || 'n/a'], ['demotion history', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.demotion_history_count ?? 0)], ['demotion reasons', formatList((setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.demotion_history || []).map((item) => item.reason || item.event))], ['family sample threshold', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.sample_threshold ?? 'n/a')], diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index 0db66b0..b9bbb4a 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -203,6 +203,11 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("guardrail_resolution_count", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) self.assertIn("preferred_template_decay_status", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) self.assertIn("preferred_template_decay_template_ids", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("preference_recovery_queue", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("preference_recovery_count", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("next_preference_recovery_template_id", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("guardrail_audit_trail", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("guardrail_audit_trail_count", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["reuse_count"], 1) self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["reuse_status"], "saved_reuse") self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["outcome_status"], "useful_signal") @@ -216,6 +221,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("guardrail_resolution_prompt", summary["review_entrypoints"]["note_templates"][0]) self.assertIn("preferred_template_decay", summary["review_entrypoints"]["note_templates"][0]) self.assertIn("preferred_template_decay_reason", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("preference_recovery_status", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("preference_recovery_prompt", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("guardrail_audit_trail", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("guardrail_audit_trail_count", summary["review_entrypoints"]["note_templates"][0]) self.assertTrue(any(item["template_id"] == "next_review" for item in summary["review_entrypoints"]["note_templates"])) self.assertTrue(any(item["template_id"] == "queue_follow_up" for item in summary["review_entrypoints"]["note_templates"])) self.assertTrue(any(item["reuse_hint"] for item in summary["review_entrypoints"]["note_templates"])) @@ -240,6 +249,12 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("resolved_proof_count", summary["digest_surface"]["history_summary"]) self.assertIn("proof_resolution_state_counts", summary["digest_surface"]["history_summary"]) self.assertIn("next_proof_follow_up_prompt", summary["digest_surface"]["history_summary"]) + self.assertIn("stale_proof_follow_up_count", summary["digest_surface"]["history_summary"]) + self.assertIn("proof_follow_up_age_counts", summary["digest_surface"]["history_summary"]) + self.assertIn("proof_resolution_history", summary["digest_surface"]["history_summary"]) + self.assertIn("proof_resolution_history_count", summary["digest_surface"]["history_summary"]) + self.assertIn("oldest_proof_follow_up_kind", summary["digest_surface"]["history_summary"]) + self.assertIn("oldest_proof_follow_up_age_seconds", summary["digest_surface"]["history_summary"]) self.assertIn("refresh_prompts", summary["digest_surface"]["history_summary"]) self.assertIn("digest_history_groups", summary) self.assertIn("group_status", summary["digest_history_groups"][0]) @@ -261,6 +276,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("proof_follow_up_state", summary["digest_history_groups"][0]) self.assertIn("proof_follow_up_action_label", summary["digest_history_groups"][0]) self.assertIn("proof_resolved_state", summary["digest_history_groups"][0]) + self.assertIn("proof_follow_up_age_seconds", summary["digest_history_groups"][0]) + self.assertIn("proof_follow_up_age_band", summary["digest_history_groups"][0]) + self.assertIn("proof_follow_up_stale_queue", summary["digest_history_groups"][0]) + self.assertIn("proof_resolution_history", summary["digest_history_groups"][0]) self.assertIn("refresh_confirmation_trail", summary["digest_history_groups"][0]) self.assertIn("latest_confirmation_trail_item", summary["digest_history_groups"][0]) self.assertTrue(summary["digest_history_preview"]) @@ -304,6 +323,11 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("promotion_queue_rank", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("promotion_next_action", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("promotion_queue_item", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_decision_history", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_decision_history_count", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("review_lane_age_status", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("review_lane_age_score", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("review_lane_aging_hint", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("demotion_history", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("demotion_history_count", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("sample_threshold", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) @@ -343,6 +367,12 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("resume_queue_count", summary["scan_handoff_summary"]) self.assertIn("next_resume_marker", summary["scan_handoff_summary"]) self.assertIn("next_resume_prompt", summary["scan_handoff_summary"]) + self.assertIn("resume_queue_persistence_status", summary["scan_handoff_summary"]) + self.assertIn("resume_queue_persistence_key", summary["scan_handoff_summary"]) + self.assertIn("resolved_lane_state_counts", summary["scan_handoff_summary"]) + self.assertIn("resolved_lane_count", summary["scan_handoff_summary"]) + self.assertIn("unresolved_lane_count", summary["scan_handoff_summary"]) + self.assertIn("next_resolved_lane_prompt", summary["scan_handoff_summary"]) self.assertIn("handoff_cleanup_recommendation", summary["scan_handoff_summary"]) self.assertTrue(summary["scan_handoff_summary"]["snooze_options"]) self.assertIn("snooze_seconds", summary["scan_handoff_summary"]["handoff_markers"][0]) @@ -352,6 +382,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("lifecycle_state", summary["scan_handoff_summary"]["handoff_markers"][0]) self.assertIn("resume_queue_rank", summary["scan_handoff_summary"]["handoff_markers"][0]) self.assertIn("resume_action_label", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("resume_persistence_key", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("resume_persistence_status", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("resolved_lane_state", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("resolved_lane_prompt", summary["scan_handoff_summary"]["handoff_markers"][0]) self.assertTrue(summary["external_context_summary"]["operator_questions"]) self.assertTrue(summary["external_context_summary"]["source_cards"]) self.assertEqual(summary["external_context_summary"]["exchange_status"]["status"], "active")