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" ?{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."}
{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")}
{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."}
-{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 |
+| --- | --- |
+|  |  |
+
+
+
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()