diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index a176506cb6..5b46e1cc0a 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2772,6 +2772,7 @@ const privateConversationSchema = z.object({ ]).optional(), managed_steward: z.discriminatedUnion("available", [ z.object({available: z.literal(true), prepared: z.boolean(), scheduling_enabled: z.boolean(), + execution_available: z.boolean().optional(), execution_error_code: z.string().optional(), goal_active: z.boolean().optional(), monitor: z.object({todo_id: z.string(), status: z.string(), cadence_minutes: z.number().int().nullable(), next_due_at: z.string().nullable(), last_checked_at: z.string().nullable()}).nullable().optional(), @@ -2784,7 +2785,8 @@ const privateConversationSchema = z.object({ z.object({available: z.literal(false), error_code: z.string()}), ]).optional(), goal_id: z.string().optional(), agent_id: z.string().optional(), session_id: z.string().optional()}), - z.object({available: z.literal(false), error_code: z.string()}), + z.object({available: z.literal(false), error_code: z.string(), + execution_available: z.boolean().optional(), execution_error_code: z.string().optional()}), ]).optional(), agent_candidates: z.array(privateAgentSessionSchema).default([]), agent_targets: z.array(privateAgentTargetSchema).default([]), }); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx index 539dd3e51d..94bbc9314c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx @@ -193,7 +193,7 @@ export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[ {row.recovery_count > 0 ?

{zh ? "存在尚未确认的发送回执。服务会读取原回执恢复;不要重新发送同一任务。检查 App 登录、权限和原会话后刷新状态。" : "A send receipt is unconfirmed. The service reads the original receipt to recover; avoid resending the same task. Check this App login, permissions and original Session, then refresh status."}

: null} {!row.context_available ?

{zh ? "工作区授权已失效;请恢复原工作区或重新选择。旧会话不会移到其它工作区。" : "The workspace grant is unavailable. Restore the original workspace or select a new one; the old Session will not move."}

: null} {row.context_kind === "project" && row.audience !== "group" ? : null} - {row.context_kind === "steward" && row.audience === "owner" && row.executor_endpoint_id === "codex" + {row.context_kind === "steward" && row.audience === "owner" && row.managed_steward !== undefined ? void act(() => prepareManagedSteward(row.binding_id))} onMonitor={(operation, cadence) => void act(() => configureStewardMonitor(row.binding_id, operation, cadence))} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/steward-managed-mode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/steward-managed-mode.tsx index f4bef990b9..bb6b622ce0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/steward-managed-mode.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/steward-managed-mode.tsx @@ -15,31 +15,37 @@ export function StewardManagedMode({state, zh, busy, available, onPrepare, onMon const [cadence, setCadence] = useState(initial ?? 30); useEffect(() => setCadence(initial ?? 30), [initial, monitorId]); if (state === undefined) return null; + const executionAvailable = state.execution_available !== false; + const canConfigure = available && executionAvailable; return
{zh ? "原生周期监控" : "Native periodic monitoring"} + {!executionAvailable ?

{zh + ? "当前执行器不支持此原生周期任务;原计划、窗口和回执均保留,执行与修改暂不可用。" + : "The current executor does not support this native scheduled work. Original schedules, windows and receipts are retained; execution and changes are unavailable."}

: null} {!state.available ?

{zh ? "管家工作身份暂不可用;请检查原会话和连接后重试。" : "The steward identity is unavailable. Check the original conversation and connection, then retry."}

: state.prepared ? <> -

{state.scheduling_enabled +

{!executionAvailable ? (zh ? "原监控配置已保留。" : "Original monitoring configuration retained.") : state.scheduling_enabled ? (zh ? "授权 Goal 状态监控已启用。" : "Authorized Goal monitoring is enabled.") : state.monitor ? (zh ? "状态监控已暂停。" : "Goal monitoring is paused.") : (zh ? "管家身份已准备,状态监控尚未启用。" : "The steward identity is prepared; monitoring is not enabled.")}

{zh ? "状态检查复用原生监控,不调用模型。" : "State checks use the native monitor without model calls."}

{state.goal_active === false ?

{zh ? "管家的工作 Goal 已停止;恢复原 Goal 后才能继续监控。" : "The steward Goal is stopped. Resume that original Goal before monitoring."}

: null} - {state.monitor ?

{state.scheduling_enabled ? (zh ? "下次检查:" : "Next check: ") : (zh ? "保留的到期时间:" : "Retained deadline: ")}{state.monitor.next_due_at ? new Date(state.monitor.next_due_at).toLocaleString(zh ? "zh-CN" : "en-US") : (zh ? "未排期" : "Unscheduled")} + {state.monitor ?

{executionAvailable && state.scheduling_enabled ? (zh ? "下次检查:" : "Next check: ") : (zh ? "保留的到期时间:" : "Retained deadline: ")}{state.monitor.next_due_at ? new Date(state.monitor.next_due_at).toLocaleString(zh ? "zh-CN" : "en-US") : (zh ? "未排期" : "Unscheduled")}
{zh ? "上次检查:" : "Last check: "}{state.monitor.last_checked_at ? new Date(state.monitor.last_checked_at).toLocaleString() : (zh ? "尚未完成首次检查" : "No completed check yet")}

: null}
-
- {state.monitor?.status === "open" ? : null}
- + {state.monitor?.status === "open" ? : null} + : <>

{zh ? "为当前管家准备一个可见的 LoopX 工作身份,保留原对话和权限。此步骤不会启动定时任务或模型。" : "Prepare one visible LoopX identity for this steward, retaining its original conversation and permissions. This step starts neither scheduled tasks nor a model."}

- } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/steward-report.tsx b/apps/presentation/dashboard/src/features/personal-workspace/steward-report.tsx index 19a7c6114d..307640d8a7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/steward-report.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/steward-report.tsx @@ -5,8 +5,9 @@ import "./steward-report.css"; type Managed = Extract, {available: true}>; -export function StewardReport({state, zh, busy, available, goalActive, onChange}: { +export function StewardReport({state, zh, busy, available, executionAvailable = true, goalActive, onChange}: { state: Managed["report"]; zh: boolean; busy: boolean; available: boolean; goalActive: boolean; + executionAvailable?: boolean; onChange: (operation: "enable" | "pause" | "cancel", schedule?: Record) => void; }) { const original = state?.available ? state.schedule : null; @@ -21,17 +22,18 @@ export function StewardReport({state, zh, busy, available, goalActive, onChange} const signature = JSON.stringify(original); useEffect(() => {if (original) {setSchedule(original); setZone(String(original.timezone));}}, [signature]); if (!state) return null; - const disabled = busy || !available; + const disabled = busy || !available || !executionAvailable; let validZone = true; try {new Intl.DateTimeFormat("en", {timeZone: zone}).format();} catch {validZone = false;} const stamp = (value?: string | null) => value ? new Date(value).toLocaleString(zh ? "zh-CN" : "en-US") : (zh ? "暂无" : "None"); return

{zh ? "定期详细汇报" : "Detailed periodic reports"}

{!state.available ?

{zh ? "汇报状态暂不可读;原计划和回执已保留。刷新连接后重试。" : "Report status is unavailable. The original schedule and receipts are retained; refresh the connection."}

: <> -

{state.enabled ? (zh ? "已启用 · 完整中文正文回此私聊" : "Enabled · Full Chinese report returns to this private Chat") +

{!executionAvailable ? (zh ? "原汇报计划已保留,当前无法运行。" : "Original report configuration retained; execution unavailable.") + : state.enabled ? (zh ? "已启用 · 完整中文正文回此私聊" : "Enabled · Full Chinese report returns to this private Chat") : state.configured ? (zh ? "已暂停 · 原窗口和回执保留" : "Paused · Original windows and receipts retained") : (zh ? "尚未启用" : "Not enabled")}

-

{zh ? "汇总全部获准本机 Goal,包括停止项和读取缺口。每天汇报前24小时,生成与实际送达分别核验;状态监控照常运行。" : "Covers every authorized local Goal, including stopped Goals and read gaps. Daily reports cover the previous 24 hours; generation and delivery are verified separately. Monitoring continues."}

+

{zh ? "汇总全部获准本机 Goal,包括停止项和读取缺口。每天汇报前24小时,生成与实际送达分别核验;监控与汇报分别配置。" : "Covers every authorized local Goal, including stopped Goals and read gaps. Daily reports cover the previous 24 hours; generation and delivery are verified separately. Monitoring and reports are configured separately."}

{state.configured ?

{zh ? "下次日历计划:" : "Next calendar deadline: "}{stamp(state.next_due_at)}
{zh ? "最近窗口:" : "Latest window: "}{stamp(state.retained_window_due_at)} · {state.delivery_status === "delivered" ? (zh ? "原私聊全文已核验送达" : "Full private reply verified") diff --git a/docs/assets/personal-workspace/steward-reports/retained-executor-before.png b/docs/assets/personal-workspace/steward-reports/retained-executor-before.png new file mode 100644 index 0000000000..ecde975245 Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/retained-executor-before.png differ diff --git a/docs/assets/personal-workspace/steward-reports/retained-executor-desktop.png b/docs/assets/personal-workspace/steward-reports/retained-executor-desktop.png new file mode 100644 index 0000000000..a90a78cc1c Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/retained-executor-desktop.png differ diff --git a/docs/assets/personal-workspace/steward-reports/retained-executor-narrow.png b/docs/assets/personal-workspace/steward-reports/retained-executor-narrow.png new file mode 100644 index 0000000000..5ee37315f8 Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/retained-executor-narrow.png differ diff --git a/docs/product/periodic-reports.md b/docs/product/periodic-reports.md index 7630febd8f..383c58831d 100644 --- a/docs/product/periodic-reports.md +++ b/docs/product/periodic-reports.md @@ -6,6 +6,29 @@ Agent Session calendar reports are implemented. Installation, an enabled subscription and a natural scheduled delivery require their own readback evidence. Preparing an identity enables neither monitoring nor reports. +Changing a previously prepared private steward to an executor without native +scheduled-work support keeps its original configuration readable in connection +settings. The monitor deadline, report schedule, retained window and delivery +state remain visible with an explicit execution-unavailable notice. Configuration +controls are disabled; inspecting retained state neither re-enables the old host +nor retries a failed window. Native admission still owns every execution and +configuration change. Restoring a supported executor restores the existing +configuration view rather than creating a replacement identity or subscription. + +The disposable `steward_report_fixture.py --retained-executor` journey prepares +one original synthetic steward and calendar window. With baseline assets the +section is absent; current packaged assets expose the retained state at 1200 +and 390 pixels. Its `restore-supported-executor` control restores the same +configuration. No wake owner is started, no model Turn is requested and no +provider write is added during inspection or restoration. This qualifies UI +readback and executor compatibility, not scheduled generation or live delivery. + +| Baseline: retained section hidden | Current: original schedule remains readable | +| --- | --- | +| ![Baseline packaged settings hide the retained schedule](../assets/personal-workspace/steward-reports/retained-executor-before.png) | ![Current packaged settings retain the schedule with unavailable execution](../assets/personal-workspace/steward-reports/retained-executor-desktop.png) | + +![The same retained schedule at 390 pixels](../assets/personal-workspace/steward-reports/retained-executor-narrow.png) + Markdown, the HTML report and its copied full text use the same source-link rule. An unchanged, well-formed HTTP(S) address is a link. Opaque evidence digests, native Task/artifact references and private relative paths stay source diff --git a/examples/personal-workspace-browser/steward_report_fixture.py b/examples/personal-workspace-browser/steward_report_fixture.py index 5e388514f8..2bb42cc1c3 100644 --- a/examples/personal-workspace-browser/steward_report_fixture.py +++ b/examples/personal-workspace-browser/steward_report_fixture.py @@ -23,11 +23,14 @@ def main() -> None: parser.add_argument("--baseline-assets-dir", type=Path) parser.add_argument("--invalid-editorial", action="store_true", help="Return a known invalid synthetic report to inspect owner cancellation and retained failure.") parser.add_argument("--host-failure", action="store_true", help="Fail the synthetic native model before an editorial result; retain the original window without retry authority.") + parser.add_argument("--retained-executor", action="store_true", help="Keep an original configured window after selecting an executor without native scheduled-work support; readback only.") journey = parser.add_mutually_exclusive_group() journey.add_argument("--managed-session", action="store_true", help="Exercise ordinary Goal Session native work with the existing task validator, instead of a scheduled report.") journey.add_argument("--managed-report", action="store_true", help="Configure a calendar report for an existing local Agent Session through the packaged Goal settings.") journey.add_argument("--managed-calendar-task", choices=("file", "sqlite"), help="Repeat a completed, independently validated Task through the real calendar and packaged Goal settings.") args = parser.parse_args() + if args.retained_executor and (args.host_failure or args.invalid_editorial or args.managed_session or args.managed_report or args.managed_calendar_task): + parser.error("retained executor readback uses the original steward journey alone") if args.host_failure and (args.invalid_editorial or args.managed_session or args.managed_calendar_task): parser.error("select the pre-editorial report failure journey alone") if args.invalid_editorial and (args.managed_session or args.managed_calendar_task): @@ -117,6 +120,23 @@ def fail_report(_adapter, _prompt, event_sink): if args.invalid_editorial: helper = root / "report_model.py" helper.write_text(helper.read_text().replace('"request_digest": request["request_digest"]', '"request_digest": "known-invalid"')) + if args.retained_executor: + from datetime import datetime, timezone + from loopx.capabilities.periodic_report.cadence_journal import admit_cadence_window + reports = StewardReports(runtime) + schedule = {"schema_version": "periodic_report_schedule_v0", "schedule_id": "retained-report", + "timezone": "UTC", "rrule": "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", "start_date": "2001-01-01"} + plan = reports.configure(binding["binding_id"], operation="enable", schedule=schedule)["plan"] + reports.configure(binding["binding_id"], operation="enable", schedule=schedule, + execute=True, expected_plan_id=plan["plan_id"]) + subscription = reports.subscription(binding["binding_id"]) + admit_cadence_window(runtime_root=runtime.coordination_runtime_root, goal_id=actor["goal_id"], + agent_id=actor["agent_id"], subscription=subscription, now=datetime.now(timezone.utc), + subscription_resolver=lambda: subscription) + current = transport.bindings.read() + selected = next(row for row in current["bindings"] if row["binding_id"] == binding["binding_id"]) + selected["executor_endpoint_id"] = "antigravity" + transport.bindings.path.write_text(json.dumps(current)) before_calls, before_messages = capture.read_bytes(), store.messages(sid) server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) server.chat_store, server.runtime_controller = store, runtime @@ -153,9 +173,16 @@ def fail_report(_adapter, _prompt, event_sink): owner_start_count = 0 restart_tokens = set() restart_refusals = set() + executor_restored = False try: while not stop.wait(.2): server.assets_dir = current_assets if (output / "use-current-assets").exists() else baseline_assets + if args.retained_executor and not executor_restored and (output / "restore-supported-executor").exists(): + current = transport.bindings.read() + selected = next(row for row in current["bindings"] if row["binding_id"] == binding["binding_id"]) + selected["executor_endpoint_id"] = "codex" + transport.bindings.path.write_text(json.dumps(current)) + executor_restored = True provider.owner_ready = not (output / "provider-unavailable").exists() owners_stopped = (output / "stop-existing-wake").exists() if owners_stopped and wake: @@ -235,6 +262,7 @@ def fail_report(_adapter, _prompt, event_sink): "provider_history_pages": sum("+chat-messages-list" in call for call in provider.calls), "existing_wake_owner_running": wake is not None, "existing_wake_owner_starts": owner_start_count, + "retained_executor_restored": executor_restored, "runtime_restarts": len(restart_tokens), "runtime_restart_rejections": len(restart_refusals), **({"managed_source_present": artifact.exists(), diff --git a/loopx/extensions/lark/private_conversation_api.py b/loopx/extensions/lark/private_conversation_api.py index 0319ab2d3b..293b01b515 100644 --- a/loopx/extensions/lark/private_conversation_api.py +++ b/loopx/extensions/lark/private_conversation_api.py @@ -58,9 +58,12 @@ def _private_conversations(self) -> None: and hasattr(self.server.lark_private_conversations, "ingress") else {}), **({"owner_attention": self.server.lark_private_conversations.owner_attention.health(row["binding_id"])} if row["context_kind"] == "steward" and row.get("audience", "owner") == "owner" else {}), - **({"managed_steward": self.server.lark_private_conversations.managed_steward.health(row["binding_id"])} + **({"managed_steward": { + **self.server.lark_private_conversations.managed_steward.health(row["binding_id"]), + **({"execution_available": False, "execution_error_code": "unsupported_executor"} + if row["executor_endpoint_id"] != "codex" else {})}} if row["context_kind"] == "steward" and row.get("audience", "owner") == "owner" - and row["executor_endpoint_id"] == "codex" + and (row["executor_endpoint_id"] == "codex" or row.get("managed_actor")) and hasattr(self.server.lark_private_conversations, "managed_steward") else {}), **deliveries.get(row["binding_id"], {"pending_count": 0, "recovery_count": 0})} for row in current["bindings"]], diff --git a/tests/test_native_steward_managed_mode.py b/tests/test_native_steward_managed_mode.py index 4f93fde4f7..0743c38f1e 100644 --- a/tests/test_native_steward_managed_mode.py +++ b/tests/test_native_steward_managed_mode.py @@ -174,7 +174,8 @@ def fail_registration(**kwargs): assert capture.read_bytes() == before -def test_http_preparation_requires_current_preview_and_reads_original_binding(steward, tmp_path, monkeypatch): +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_http_preparation_requires_current_preview_and_reads_original_binding(steward, tmp_path, monkeypatch, provider): import http.client import threading from types import SimpleNamespace @@ -183,7 +184,7 @@ def test_http_preparation_requires_current_preview_and_reads_original_binding(st isolate_sqlite_runtime(tmp_path, monkeypatch) store, runtime, _, transport, binding, capture, _ = steward sid, _ = original(steward) - storage(runtime, "sqlite") + storage(runtime, provider) server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) server.chat_store, server.runtime_controller = store, runtime server.registry_path, server.runtime_root_override = runtime.registry_path, store.root.parent @@ -257,6 +258,60 @@ def request(path, body=None, origin=None): assert row["managed_steward"]["scheduling_enabled"] is False assert row["managed_steward"]["monitor"]["status"] == "blocked" assert store.messages(sid) == before_history and capture.read_bytes() == before_calls + # Retained configuration remains inspectable after an executor change. + # Reading it never supplies the original Codex execution authority. + from datetime import datetime, timezone + from loopx.capabilities.native_chat.steward_reports import StewardReports + from loopx.capabilities.periodic_report.cadence_journal import admit_cadence_window, cadence_journal_path + + report_route = route + "/report" + report_setting = {"binding_id": binding["binding_id"], "operation": "enable", + "schedule": {"schema_version": "periodic_report_schedule_v0", "schedule_id": "retained-report", + "timezone": "UTC", "rrule": "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", "start_date": "2001-01-01"}} + code, report_preview = request(report_route, report_setting) + assert code == 200 + assert request(report_route, {**report_setting, "execute": True, + "plan_id": report_preview["plan"]["plan_id"]})[0] == 200 + reports = StewardReports(runtime) + subscription = reports.subscription(binding["binding_id"]) + actor = applied["actor"] + admitted = admit_cadence_window(runtime_root=runtime.coordination_runtime_root, + goal_id=actor["goal_id"], agent_id=actor["agent_id"], subscription=subscription, + now=datetime.now(timezone.utc), subscription_resolver=lambda: subscription) + assert admitted["status"] == "pending" + journal_path = cadence_journal_path(runtime.coordination_runtime_root, actor["goal_id"]) + journal = journal_path.read_bytes() + expected = next(row for row in request(base)[1]["connections"] + if row["binding_id"] == binding["binding_id"])["managed_steward"] + current = transport.bindings.read() + selected = next(row for row in current["bindings"] if row["binding_id"] == binding["binding_id"]) + selected["executor_endpoint_id"] = "antigravity" + transport.bindings.path.write_text(json.dumps(current)) + original_binding, registry = transport.bindings.path.read_bytes(), runtime.registry_path.read_bytes() + row = next(row for row in request(base)[1]["connections"] if row["binding_id"] == binding["binding_id"]) + state = row["managed_steward"] + assert state.pop("execution_available") is False + assert state.pop("execution_error_code") == "unsupported_executor" + assert state == expected + assert state["report"]["retained_window_due_at"] == admitted["window"]["due_at"] + for endpoint, body in [(route, {"binding_id": binding["binding_id"]}), + (monitor_route, setting), (report_route, report_setting), + (report_route, {"binding_id": binding["binding_id"], "operation": "pause"}), + (report_route, {"binding_id": binding["binding_id"], "operation": "cancel"})]: + assert request(endpoint, body)[0] == 409 + assert journal_path.read_bytes() == journal + assert transport.bindings.path.read_bytes() == original_binding + assert runtime.registry_path.read_bytes() == registry + assert store.messages(sid) == before_history and capture.read_bytes() == before_calls + selected["executor_endpoint_id"] = "codex" + transport.bindings.path.write_text(json.dumps(current)) + restored = next(row for row in request(base)[1]["connections"] if row["binding_id"] == binding["binding_id"]) + assert restored["managed_steward"] == expected + selected["executor_endpoint_id"] = "antigravity" + selected.pop("managed_actor") + transport.bindings.path.write_text(json.dumps(current)) + unprepared = next(row for row in request(base)[1]["connections"] if row["binding_id"] == binding["binding_id"]) + assert "managed_steward" not in unprepared finally: server.shutdown() server.server_close()