Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2622,6 +2622,11 @@ const privateConversationSchema = z.object({
pending_count: z.number().int(), unconfirmed_count: z.number().int(), failed_count: z.number().int()}),
z.object({available: z.literal(false), error_code: z.string()}),
]).optional(),
managed_steward: z.discriminatedUnion("available", [
z.object({available: z.literal(true), prepared: z.boolean(), scheduling_enabled: z.literal(false),
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()}),
]).optional(),
agent_candidates: z.array(privateAgentSessionSchema).default([]), agent_targets: z.array(privateAgentTargetSchema).default([]),
});
const privateConversationStopRecoverySchema = z.object({binding_id: z.string(), project_ref: z.string(),
Expand All @@ -2647,6 +2652,23 @@ export async function disconnectPrivateConversation(bindingId: string, revision:
}));
}

const stewardPreparationSchema = z.object({ok: z.literal(true), applied: z.boolean(), plan: z.object({
schema_version: z.literal("loopx_steward_managed_mode_plan_v0"), plan_id: z.string().regex(/^[a-f0-9]{64}$/),
session_id: z.string(), goal_id: z.string(), already_prepared: z.boolean(),
scheduling_enabled: z.literal(false), model_invoked: z.literal(false),
})});
export async function prepareManagedSteward(bindingId: string) {
const path = "/api/chat/lark/private-conversations/managed-steward";
const send = (body: unknown) => requestJson<unknown>(path, {method: "POST",
headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
const preview = stewardPreparationSchema.parse(await send({binding_id: bindingId, execute: false}));
if (preview.plan.already_prepared) return preview;
const applied = stewardPreparationSchema.parse(await send({binding_id: bindingId, execute: true,
session_id: preview.plan.session_id, plan_id: preview.plan.plan_id}));
if (!applied.applied) throw new Error("The original steward identity has not been prepared.");
return applied;
}

export async function changePrivateAgentTarget(bindingId: string, revision: number, target: {session_id: string} | {target_ref: string}) {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations/agent-targets", {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({binding_id: bindingId, revision, ...target}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {useEffect, useRef, useState} from "react";
import {connectPrivateConversation, disconnectPrivateConversation, fetchPrivateConversations,
changePrivateAgentTarget, changeOwnerNotifications, fetchChatProjects, fetchChatCapabilities, fetchLarkGroupChats, type PrivateConversation,
changePrivateAgentTarget, changeOwnerNotifications, prepareManagedSteward, fetchChatProjects, fetchChatCapabilities, fetchLarkGroupChats, type PrivateConversation,
discoverBoundChatSession, type BoundConversationTarget,
type ChatProject, type LarkApp, type PrivateConversationStopRecovery} from "../../data/chat";
import {useWorkspaceI18n} from "./i18n";
import {StewardManagedMode} from "./steward-managed-mode";
import "./private-conversation.css";

export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[]; onOpenOriginal?: (target: BoundConversationTarget) => void}) {
Expand Down Expand Up @@ -156,6 +157,9 @@ export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[
{row.recovery_count > 0 ? <p role="status">{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."}</p> : null}
{!row.context_available ? <p role="alert">{zh ? "工作区授权已失效;请恢复原工作区或重新选择。旧会话不会移到其它工作区。" : "The workspace grant is unavailable. Restore the original workspace or select a new one; the old Session will not move."}</p> : null}
{row.context_kind === "project" && row.audience !== "group" ? <PrivateAgentTargets row={row} revision={revision} zh={zh} busy={busy} act={act}/> : null}
{row.context_kind === "steward" && row.audience === "owner" && row.executor_endpoint_id === "codex"
? <StewardManagedMode state={row.managed_steward} zh={zh} busy={busy} available={row.context_available}
onPrepare={() => void act(() => prepareManagedSteward(row.binding_id))}/> : null}
{row.context_kind === "steward" && row.goal_scope !== "all_registered" ? <button disabled={busy || !row.context_available} onClick={() => void act(() => connectPrivateConversation(row.app_ref, row.project_ref, row.executor_endpoint_id, "steward"))} type="button">{zh ? "授权全部已注册工作" : "Authorize all registered work"}</button> : null}
<button disabled={busy} onClick={() => void act(() => disconnectPrivateConversation(row.binding_id, revision))} type="button">{zh ? "解绑" : "Disconnect"}</button>
</article>)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {PrivateConversation} from "../../data/chat";

/** Identity preparation is deliberately distinct from enabling scheduled work. */
export function StewardManagedMode({state, zh, busy, available, onPrepare}: {
state: PrivateConversation["managed_steward"]; zh: boolean; busy: boolean;
available: boolean; onPrepare: () => void;
}) {
if (state === undefined) return null;
return <details>
<summary>{zh ? "原生周期监控" : "Native periodic monitoring"}</summary>
{!state.available ? <p role="status">{zh ? "管家工作身份暂不可用;请检查原会话和连接后重试。" : "The steward identity is unavailable. Check the original conversation and connection, then retry."}</p>
: state.prepared ? <p role="status">{zh ? "管家身份已准备。定时任务尚未启用。" : "The steward identity is prepared. Scheduled tasks are not enabled."}</p>
: <>
<p>{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."}</p>
<button type="button" disabled={busy || !available} onClick={onPrepare}>
{zh ? "准备管家身份" : "Prepare steward identity"}
</button>
</>}
</details>;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
175 changes: 175 additions & 0 deletions docs/product/periodic-reports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Managed steward monitoring and periodic reports

Status: native identity preparation is implemented. Scheduled execution and
daily portfolio reports remain the target described below; they are not enabled
by preparing an identity.

The steward should become a persistent LoopX-managed Agent. Its original private
conversation remains the user's entry point. Native monitor Todos, Turn
admission, scheduling and periodic-report publication own recurring work.

## What exists and what is missing

The current components already provide most of the lifecycle:

- `control_plane/scheduler/monitor_todo.py` and the typed scheduler project due
monitor work and the next deadline.
- `control_plane/turn_driver/` provides registered Goal/Agent admission, single
executor ownership, recovery, settlement and quota lineage.
- `capabilities/periodic_report/cadence.py`, `cadence_journal.py` and
`cadence_runtime.py` evaluate calendar windows, retain a pending reporter and
expose due report intents at Turn start.
- Native external-conversation admission and the existing Lark reply journal
retain original requests, audience identity and delivery evidence.

The private steward currently uses the special `loopx-manager` conversation
identity. It does not have its own registered execution Goal/Agent. The existing
Chat LoopX-mode endpoint accepts a project Goal Chat, and rejects this steward.
Also, a report Turn-start hook does not independently wake a sleeping actor.
These are integration gaps; calendar calculation alone does not enable a report.

Chat `/goal` transport supervision and a LoopX-managed Turn are distinct. Merely
relaxing `validate_goal_chat`, changing a Session field, adding a prompt, or
starting a Codex native Goal does not establish the LoopX lifecycle below.

## One persistent steward actor

Extend the existing source-bound upgrade/activation path. A typed preview must
identify the original private binding, its conversation, the steward workspace,
the persistent execution identity and the current host/profile. Apply is an
idempotent native transaction using the owner's existing authorization.

Register one visible steward Goal/Agent, rather than creating a hidden Goal for
each monitor or borrowing a project worker's identity. The Goal owns the
steward's durable monitoring and reporting lifecycle. Its execution authority
does not include performing other projects' business work.

The upgrade retains private chat identity, visible history, queued and accepted
requests, unfinished delegations, original delivery routes and existing grants.
In-flight work stays with its original accepted Turn; the upgrade waits for an
eligible idle boundary. If host tools require a replacement upstream thread,
preserve the visible conversation and history, and record that transport change.
Do not claim to preserve a physical thread that was actually replaced.

Keep the steward's existing portfolio-read authority separate from its execution
actor. Resolve both through their native owners on every admission. Registering
the actor does not grant new project writes or broaden the original audience.
Keep ordinary project assistants unchanged.

### Identity preparation available now

In the private steward's connection settings, expand **Native periodic
monitoring** and choose **Prepare steward identity**. The same gesture obtains
a current preview and applies it. A stale preview, unverified provider, changed
grant, active Turn or queued user request refuses preparation. Resolve that
condition and retry in the original conversation.

The existing local-owner endpoint is
`POST /api/chat/lark/private-conversations/managed-steward`. A preview accepts
`binding_id` and optional `session_id`. Apply additionally requires
`execute: true` and the preview's `plan_id`. Callers cannot select a Goal, Agent,
workspace, provider account or new grant. Existing loopback/origin checks apply.

The typed conversation owner names one Goal and registered `steward` Agent from
the original binding. Existing native Goal creation and configuration owners
initialize File or SQLite storage according to machine settings. The existing
conversation binding stores only the resulting identity reference, including
the original Session and native creation operation. Source-session registries
retain their lifetime token; ordinary registries retain the existing nullable
lifetime contract. Preparation does not strengthen that legacy contract.

A lost registration or binding response can resume the same verified creation;
it cannot adopt an unrelated Goal or recreate a missing bound Goal. Repeating
an applied request verifies the same registration and returns the same actor.
The original Session, history, upstream thread, accepted requests and portfolio
grant are retained. No model, native monitor, scheduler host or extra message
consumer starts. The UI explicitly shows **Scheduled tasks are not enabled**.

This is a separately reviewable preparation boundary. There is no active host
to stop or migrate back. Disconnecting the conversation uses its existing owner
and leaves the registered, unscheduled Goal visible; it does not delete history
or accepted work. Removing that Goal remains an explicit native Goal lifecycle
operation. Activation and its pause/rollback controls are the next integration
stage, not an implied consequence of this preparation.

## Native scheduling and execution

The intended flow is:

```text
original private ingress -> original steward conversation
|
steward Goal / Agent
|
native monitor/report deadline -> scheduler admission
|
one managed LoopX Turn
|
periodic-report generation/publication
|
original private reply + provider readback
```

Represent monitors as native `continuous_monitor` Todos under this actor. Reuse
their due times, observations and lifecycle; do not create another task store,
cron, model loop or message consumer. Expose the report calendar's next boundary
to that same scheduler frontier. The periodic-report calendar/journal remains
the owner of report windows; a monitor deadline is its scheduling projection,
not a second publication cursor.

The existing runtime host checks the frontier without invoking a model. When
nothing is due, it sleeps until the next eligible native deadline. Only an
admitted Turn can invoke the model. A fixed daily report remains due even if
project state has not changed. Preserve the owner's minimum scheduling cadence;
align eligible calendar wakes to the requested wall time and disclose lateness
when the machine, host or actor cannot run then.

User messages, due monitors, outstanding original replies and reports use the
same actor's queue and admission. A busy actor retains due work and rechecks it
at the normal terminal boundary. It does not create another executor. Existing
priority, replan, budget, stop and settlement policies remain effective. Pausing
the steward prevents new scheduled model Turns without discarding accepted work
or historical results.

## Daily portfolio report

Configure the original steward's daily report for 09:00 `Asia/Shanghai`. At
scheduled boundary T, freeze the reporting interval as `[T-24h,T)`. Keep scheduled
boundary, actual generation time and actual delivery time separate.

Read all locally authorized registered Goals, including stopped Goals, through
their current native read surfaces. Do not reactivate them. Collect window
progress, relevant active/completed Todos, actual artifacts and validation,
unresolved requests/returns and concrete blockers. Distinguish reported claims
from independent verification, and report incomplete coverage explicitly.
Reconcile current approvals and dependencies before proposing a new owner ask.

The steward produces the full Chinese body and returns it to the original
private chat. A summary, queued outbox row, attachment alone or assistant-side
completion flag is insufficient evidence that the user received the report.

Use a stable identity for each scheduled window. Retain generation and delivery
progress separately. A restart resumes that window; confirmed delivery is not
sent again, and uncertain sends are read back before retry. After extended
offline time, produce the most recent complete window and disclose missing
windows, rather than starting an unbounded series of model calls.

## Implementation order and acceptance

1. Qualify the native steward upgrade and rollback at an idle boundary. Verify
one registered actor, preserved original history/requests/grants, and no
change to ordinary assistants or other project Agents.
2. Connect a real monitor deadline to that actor's native host and Turn driver.
Verify not-due checks invoke no model; due, busy, pause and restart cases
preserve one executor and native settlement.
3. Add the authorized portfolio report source to periodic-report, project its
next calendar deadline and configure the original private delivery route.
Reuse the existing report settings and monitor views for edit/pause/cancel,
next due time, last result, coverage and actual delivery status.
4. Run semantic File/SQLite and host tests for fixed windows, unchanged-but-due
reports, offline recovery, original audience, revoked grants, duplicate or
unknown sends, partial delivery and late callbacks after cancellation.
5. Qualify the exact final diff, publish and merge under the existing repository
policy, install it, then independently verify original-service adoption,
actual configuration and an end-to-end report. The first natural scheduled
report remains a separate acceptance event.
2 changes: 2 additions & 0 deletions loopx/capabilities/native_chat/conversation_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ def configure(self, *, transport_ref: str, project_ref: str,
if "goal_scope" in previous:
candidate["goal_scope"] = previous["goal_scope"]
candidate["binding_id"] = previous["binding_id"]
if previous.get("managed_actor") is not None:
candidate["managed_actor"] = previous["managed_actor"]
result = self._core("collaboration.conversation.binding", {
"current": current, "expected_revision": current.get("revision"), "operation": "configure",
"binding": candidate, "observation": observation, "available_projects": self.projects.available(),
Expand Down
Loading
Loading