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
13 changes: 13 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2504,6 +2504,12 @@ const privateConversationSchema = z.object({
goal_scope: z.enum(["all_registered", "selected"]).nullable().optional(),
audience: z.enum(["owner", "group"]).default("owner"), group_count: z.number().int().default(0),
pending_count: z.number().int(), recovery_count: z.number().int(),
owner_attention: z.discriminatedUnion("available", [
z.object({available: z.literal(true), enabled: z.boolean(), revision: z.number().int(),
last_checked_at: z.number().nullable(), error_code: z.string().nullable(),
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(),
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 Down Expand Up @@ -2535,6 +2541,13 @@ export async function changePrivateAgentTarget(bindingId: string, revision: numb
}));
}

export async function changeOwnerNotifications(bindingId: string, bindingRevision: number, revision: number, enabled: boolean) {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations/owner-notifications", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({binding_id: bindingId, binding_revision: bindingRevision, revision, enabled}),
}));
}


// Display validation only; finding status semantics remain owned by Explore.
const exploreResultPageSchema = z.object({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {useEffect, useState} from "react";
import {connectPrivateConversation, disconnectPrivateConversation, fetchPrivateConversations,
changePrivateAgentTarget, fetchChatProjects, fetchChatCapabilities, fetchLarkApps, fetchLarkGroupChats, type PrivateConversation,
changePrivateAgentTarget, changeOwnerNotifications, fetchChatProjects, fetchChatCapabilities, fetchLarkApps, fetchLarkGroupChats, type PrivateConversation,
type ChatProject, type LarkApp, type PrivateConversationStopRecovery} from "../../data/chat";
import {useWorkspaceI18n} from "./i18n";
import "./private-conversation.css";
Expand Down Expand Up @@ -103,6 +103,21 @@ export function PrivateConversationPanel() {
{row.audience === "group" ? <p>{zh ? `群话题助手 · 已授权 ${row.group_count} 个群 · 独立工作区隔离` : `Group topic assistant · ${row.group_count} authorized groups · Isolated workspace`}</p> : null}
<p>{row.executor_endpoint_id} · {zh ? "监听状态" : "Listener"}: {listenerLabel(row.listener_status)}</p>
<p>{zh ? `待处理或回复:${row.pending_count}` : `Pending execution or reply: ${row.pending_count}`}</p>
{row.context_kind === "steward" && row.audience === "owner" ? <div>
{row.owner_attention?.available ? <>
<label style={{display: "flex", alignItems: "center", gap: 8, minHeight: 44}}><input type="checkbox" style={{width: "auto", margin: 0, flexShrink: 0}} checked={row.owner_attention.enabled} disabled={busy}
onChange={event => {const setting = row.owner_attention; if (setting?.available) void act(() =>
changeOwnerNotifications(row.binding_id, revision, setting.revision, event.target.checked));}}/>
{zh ? "自动通知新的用户待办" : "Automatically notify new owner tasks"}</label>
<p>{zh ? "开启后,管家服务自动通知授权范围内新增或实质变更的用户待办,发到原本人私聊。首次开启保留现有待办,不补发历史通知。关闭后暂停发送,保留原回执。" : "The steward service sends new or materially changed owner tasks within its scope to the original owner private Chat. First enable keeps existing tasks without a historical burst. Disabling pauses delivery and retains receipts."}</p>
{row.owner_attention.enabled ? <p role="status">{row.owner_attention.error_code || row.owner_attention.failed_count > 0
? (zh ? "通知暂未完成。请检查此 App 登录、授权工作与原私聊;原待办和回执仍保留,发送结果不明的通知不会重发。" : "Notifications need attention. Check this App login, authorized work and original Chat. Tasks and receipts are retained; ambiguous sends are not repeated.")
: row.owner_attention.last_checked_at === null ? (zh ? "等待首次后台检查" : "Waiting for the first background check")
: Date.now() / 1000 - row.owner_attention.last_checked_at > 120 ? (zh ? "后台检查尚未恢复,请确认管家服务正在运行。" : "Background checks have not resumed. Check that the steward service is running.")
: (zh ? `后台自动检查中 · 待发 ${row.owner_attention.pending_count}` : `Background checks active · ${row.owner_attention.pending_count} pending`)}</p> : null}
{row.owner_attention.unconfirmed_count > 0 ? <p role="status">{zh ? "有通知发送结果尚未确认;服务只恢复原回执,不重复发送。" : "A notification receipt is unconfirmed; the service recovers the original receipt without resending."}</p> : null}
</> : <p role="status">{zh ? "自动通知状态尚未确认,请刷新后重试。" : "Automatic notification state is unavailable. Refresh to retry."}</p>}
</div> : null}
{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}
Expand Down
38 changes: 38 additions & 0 deletions loopx/capabilities/manager_context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,44 @@ worker planning workflow; do not overwrite the original receipt. This feature
adds no periodic automation, forced wakeup, Todo priority or protected-operation
permission. Existing private inbox records are retained when delivery is revoked.

## Automatic owner task notifications

An existing owner-only private steward can enable **Automatically notify new
owner tasks** in Settings → Lark. This is an explicit, reversible notification
setting; existing installations stay off until enabled. First enable baselines
the current tasks without sending a historical burst. The ordinary steward
service then discovers new and materially changed `user_action` / `user_gate`
Todos in its current Goal scope, including subsequently registered Goals when
the binding already authorizes all registered work. No Codex heartbeat, model
Turn, hidden Goal, delegated worker or additional event consumer is involved.
This notice grants no execution or decision authority and does not complete a
Todo. Worker-result returns keep their separate existing delivery contract.

Discovery reads every authorized Goal through the native Todo reader and
restores exact source text before preparing a notice. A failed or incomplete
read retains prior work; it cannot infer task removal. Completed, withdrawn,
superseded or out-of-scope sources cancel unsent notices. A local typed planner
owns subject identity, meaningful-content changes and reopening generations;
this is a local notification journal, not another Todo authority.

The existing four-worker outbound pool reserves at most one slot for bounded
notification reconciliation only when a notification setting is enabled;
the default-off path retains all four reply slots and performs no Todo scan.
Each durable per-binding outbox has a process
fence. Before each send, Core rechecks the exact steward, App, owner, current
scope and original private-message provenance. Settings cannot supply another
destination. A frozen intent precedes the provider write; the independent
readback must match the exact message, App, private Chat and complete text.
Restart recovers an existing locator by reading it. A create without a returned
locator remains unconfirmed and is never blindly repeated; only a proven
pre-spawn failure permits up to three bounded attempts. Oversized complete
notices fail visibly instead of silently truncating decision terms.

The setting and content-free pending/unconfirmed/failure health are read back
in the same settings panel. Disable remains local even when provider auth is
down, pauses delivery and retains all frozen receipts. Re-enable preserves
those receipts and discovers changes since the previous successful snapshot.

## Scoped project conversations

Local Goal Chat reuses this inbox and return path for members registered in that
Expand Down
1 change: 1 addition & 0 deletions loopx/chat_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,7 @@ def do_POST(self) -> None:
CHAT_LARK_CONNECTIONS_PATH: self._lark_connect,
PRIVATE_CONVERSATIONS_PATH: self._private_conversation_connect,
PRIVATE_CONVERSATIONS_PATH + "/agent-targets": self._private_conversation_agent_target,
PRIVATE_CONVERSATIONS_PATH + "/owner-notifications": self._private_conversation_owner_notifications,
**self._configuration_post_routes(),
**self._ssh_source_post_routes(),
}
Expand Down
4 changes: 4 additions & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,10 @@ export function createEffectRuntimeHandlers(
["presentation.decision_notice.project", lazyHandler(() => import("./presentation/decision_notice.ts"), ({projectDecisionNotice}) => projectDecisionNotice)],
["presentation.decision_notice.validate_references", lazyHandler(() => import("./presentation/decision_notice.ts"), ({validateDecisionNoticeReferences}) => validateDecisionNoticeReferences)],
["presentation.goal_attention.project", lazyHandler(() => import("./presentation/goal_attention.ts"), ({projectGoalAttention}) => projectGoalAttention)],
["presentation.owner_attention.plan", lazyHandler(() => import("./presentation/owner_attention.ts"), ({planOwnerAttention}) => planOwnerAttention)],
["presentation.owner_attention.observe", lazyHandler(() => import("./presentation/owner_attention.ts"), ({observeOwnerAttention}) => observeOwnerAttention)],
["presentation.owner_attention.health", lazyHandler(() => import("./presentation/owner_attention.ts"), ({ownerAttentionHealth}) => ownerAttentionHealth)],
["presentation.owner_attention.references", lazyHandler(() => import("./presentation/owner_attention.ts"), ({ownerAttentionReferences}) => ownerAttentionReferences)],
["presentation.goal_attention.bound", lazyHandler(() => import("./presentation/goal_attention.ts"), ({boundGoalAttention}) => boundGoalAttention)],
["presentation.goal_attention.reconcile", lazyHandler(() => import("./presentation/goal_attention.ts"), ({reconcileGoalAttention}) => reconcileGoalAttention)],
["presentation.action_review_plan.compile", lazyHandler(() => import("./presentation/action_review_plan.ts"), ({compileActionReviewPlan}) => (params) =>
Expand Down
144 changes: 144 additions & 0 deletions loopx/control_plane/presentation/owner_attention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/** Notification discovery owns no Todo lifecycle or execution authority.
* Complete native snapshots select subjects; provider journals prove delivery. */
import type {JsonObject} from "../effect_program.ts";
import {requireInteger, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts";
import {canonicalAuthoritySha256 as digest} from "../coordination/authority_store_codec.ts";
import {projectGoalAttention} from "./goal_attention.ts";

const records = (value: unknown, name: string): JsonObject[] => {
if (!Array.isArray(value)) throw new TypeError(`${name} must be a complete array`);
return value.map(row => requireJsonObject(row, name));
};
function active(row: JsonObject, goalId: string): boolean {
if (row.goal_id != null && row.goal_id !== goalId) throw new Error("foreign owner Todo");
// Reuse the existing attention owner, one source at a time. Its presentation
// bound cannot omit this single subject; incomplete notice terms are still
// restored independently from the native exact reader before delivery.
return projectGoalAttention({goal_id: goalId, todos: [row], blockers: [], limit: 1}).items.length === 1;
}

export function ownerAttentionReferences(input: JsonObject): JsonObject {
const goalId = requireNonEmptyString(input.goal_id, "goal_id");
return {todo_ids: records(input.todos, "todos").filter(row => active(row, goalId))
.map(row => requireNonEmptyString(row.todo_id, "todo_id"))};
}

/** Local provider-journal vocabulary; never canonical Todo/Run status. */
function readState(raw: unknown): JsonObject {
const state = requireJsonObject(raw, "state");
if (state.schema_version !== "lark_owner_attention_v0" || typeof state.enabled !== "boolean"
|| typeof state.initialized !== "boolean" || requireInteger(state.revision, "revision") < 0)
throw new Error("owner notification state is unavailable");
records(state.subjects, "subjects");
for (const row of records(state.outbox, "outbox")) {
if (!["pending", "sending", "unconfirmed", "delivered", "failed", "superseded"].includes(String(row.status)))
throw new Error("unknown notification receipt state");
}
return state;
}

export function ownerAttentionHealth(input: JsonObject): JsonObject {
const state = readState(input.state);
const rows = records(state.outbox, "outbox");
return {available: true, enabled: state.enabled, revision: state.revision,
last_checked_at: state.last_checked_at ?? null, error_code: state.error_code ?? null,
pending_count: rows.filter(row => row.status === "pending").length,
unconfirmed_count: rows.filter(row => row.status === "sending" || row.status === "unconfirmed").length,
failed_count: rows.filter(row => row.status === "failed").length};
}

export function planOwnerAttention(input: JsonObject): JsonObject {
const state = readState(input.state);
if (input.source_complete !== true) throw new Error("owner attention source is incomplete");
const previous = records(state.subjects, "subjects");
const outbox = records(state.outbox, "outbox").map(row => ({...row}));
const current = new Map<string, JsonObject>();
for (const goal of records(input.goals, "goals")) {
const goalId = requireNonEmptyString(goal.goal_id, "goal_id");
for (const todo of records(goal.todos, "todos")) {
if (!active(todo, goalId)) continue;
if (todo.goal_id != null && todo.goal_id !== goalId) throw new Error("foreign owner Todo");
const todoId = requireNonEmptyString(todo.todo_id, "todo_id");
const text = requireNonEmptyString(todo.text, "todo.text");
const terms: JsonObject = {text};
for (const key of ["note", "reason", "evidence", "decision_scope", "required_decision_scopes"]) {
if (todo[key] != null && todo[key] !== "") terms[key] = todo[key];
}
const key = `${goalId}/${todoId}`;
if (current.has(key)) throw new Error("duplicate owner Todo");
current.set(key, {key, goal_id: goalId, todo_id: todoId, terms, digest: digest(terms), active: true});
}
}
const subjects: JsonObject[] = [...current.values()].map(row => {
const old = previous.find(item => item.key === row.key);
const changed = !old || old.active !== true || old.digest !== row.digest;
const generation = Number(old?.generation ?? 0) + Number(changed);
if (state.initialized === true && changed) {
const id = digest([row.key, generation, row.digest]);
const terms = row.terms as JsonObject;
const body = [`待你处理 · ${row.goal_id}`, String(terms.text),
...["note", "reason", "evidence", "decision_scope", "required_decision_scopes"].flatMap(key =>
terms[key] == null ? [] : [`${({note: "说明", reason: "原因", evidence: "依据",
decision_scope: "决定范围", required_decision_scopes: "决定范围"} as Record<string, string>)[key]}:${
typeof terms[key] === "string" ? terms[key] : JSON.stringify(terms[key])}`]),
"可在原管家对话中查看或答复这项待办。"].join("\n\n");
outbox.push({id, key: row.key, generation, digest: row.digest, goal_id: row.goal_id,
todo_id: row.todo_id, body, status: Buffer.byteLength(body, "utf8") > 20000 ? "failed" : "pending",
error_code: Buffer.byteLength(body, "utf8") > 20000 ? "notice_content_too_large" : null,
attempts: 0, next_attempt_at: 0});
}
const {terms: _terms, ...subject} = row;
return {...subject, generation};
});
for (const row of previous) {
if (!current.has(String(row.key))) subjects.push({...row, active: false});
}
for (const row of outbox) {
const subject = subjects.find(item => item.key === row.key);
const obsolete = subject?.active !== true || subject.digest !== row.digest || subject.generation !== row.generation;
// A crash after starting a write is ambiguous even without a locator.
if (row.status === "sending") {row.status = "unconfirmed"; row.error_code = "send_outcome_unknown";}
if (obsolete && ["pending", "failed"].includes(String(row.status))) row.status = "superseded";
row.source_current = !obsolete;
}
return {...state, initialized: true, subjects, outbox};
}

/** Fold provider observations, preserving frozen intent and ambiguous writes. */
export function observeOwnerAttention(input: JsonObject): JsonObject {
const state = readState(input.state);
const id = requireNonEmptyString(input.id, "id");
const event = requireNonEmptyString(input.event, "event");
const outbox = records(state.outbox, "outbox").map(row => ({...row}));
const row = outbox.find(item => item.id === id);
if (!row) throw new Error("unknown owner notice");
const now = Number(input.now);
if (!Number.isFinite(now) || now < 0) throw new Error("invalid notice observation time");
if (event === "write_started") {
if (row.status !== "pending" || row.source_current !== true || Number(row.next_attempt_at) > now)
throw new Error("notice is not ready for delivery");
row.intent = requireJsonObject(input.intent, "intent");
row.status = "sending"; row.attempts = Number(row.attempts) + 1; row.error_code = null;
} else if (event === "spawn_failed") {
if (row.status !== "sending") throw new Error("notice write was not started");
row.status = Number(row.attempts) >= 3 ? "failed" : "pending";
row.error_code = "provider_spawn_failed";
row.next_attempt_at = now + 30 * Number(row.attempts);
} else if (event === "write_observed") {
if (row.status !== "sending") throw new Error("notice write was not started");
row.status = "unconfirmed";
row.message_id = typeof input.message_id === "string" && input.message_id ? input.message_id : null;
row.error_code = row.message_id ? "readback_pending" : "send_outcome_unknown";
row.next_attempt_at = now;
} else if (event === "readback_verified" || event === "readback_pending") {
if (row.status !== "unconfirmed" || !row.message_id) throw new Error("notice has no provider locator");
row.status = event === "readback_verified" ? "delivered" : "unconfirmed";
row.error_code = event === "readback_verified" ? null : "readback_pending";
row.next_attempt_at = now + 60;
if (event === "readback_verified") {
row.readback = requireJsonObject(input.readback, "readback");
row.verified_at = now;
}
} else throw new Error("unknown notice observation");
return {...state, outbox};
}
Loading
Loading