diff --git a/.github/workflows/dsh-plugin.yml b/.github/workflows/dsh-plugin.yml index f78bddb0ea..e7af43ee47 100644 --- a/.github/workflows/dsh-plugin.yml +++ b/.github/workflows/dsh-plugin.yml @@ -22,6 +22,30 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + frozen-configuration: + name: dsh-pnpm-frozen (${{ matrix.pnpm }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + pnpm: ["9.15.9", "10.33.0", "12.10.1"] + defaults: + run: + working-directory: packages/dsh-loopx-plugin + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: "24" + - uses: pnpm/action-setup@v4 + with: + version: ${{ matrix.pnpm }} + - name: Qualify the frozen override configuration without dependency scripts + run: pnpm install --frozen-lockfile --lockfile-only --ignore-scripts + - name: Keep the qualified dependency graph unchanged + run: git diff --exit-code -- pnpm-lock.yaml + distribution: name: dsh-plugin (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index c3e646afe2..4e6b92edfd 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2303,9 +2303,10 @@ const larkAppsSchema = z.object({ })), }); -export async function fetchLarkApps() { +export async function fetchLarkApps(options?: {verify: boolean; appRef?: string; signal?: AbortSignal}) { + const query = options ? new URLSearchParams({verify: String(options.verify), ...(options.appRef ? {app_ref: options.appRef} : {})}) : null; return larkAppsSchema.parse( - await requestJson("/api/chat/lark/apps"), + await requestJson(`/api/chat/lark/apps${query ? `?${query}` : ""}`, {signal: options?.signal}), ).apps; } @@ -2464,9 +2465,9 @@ const larkConnectionsSchema = z.object({ })), }); -export async function fetchLarkConnections() { +export async function fetchLarkConnections(signal?: AbortSignal) { return larkConnectionsSchema.parse( - await requestJson("/api/chat/lark/connections"), + await requestJson("/api/chat/lark/connections", {signal}), ).connections; } diff --git a/apps/presentation/dashboard/src/data/use-lark-apps.ts b/apps/presentation/dashboard/src/data/use-lark-apps.ts new file mode 100644 index 0000000000..82d58d3bbe --- /dev/null +++ b/apps/presentation/dashboard/src/data/use-lark-apps.ts @@ -0,0 +1,47 @@ +import {useCallback, useEffect, useRef, useState} from "react"; +import {fetchLarkApps, type LarkApp} from "./chat"; + +/** One settings mount owns one read, shared by its connection/setup panels. + * Observed readiness is presentation only; operations revalidate at Core. + */ +export function useLarkApps() { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const current = useRef(null); + const refresh = useCallback(async () => { + current.current?.abort(); + const controller = new AbortController(); + current.current = controller; + setApps([]); setError(null); setLoading(true); + const active = () => !controller.signal.aborted; + try { + const profiles = await fetchLarkApps({verify: false, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(7000)])}); + if (!active()) return; + setApps(profiles); + let next = 0; + // A slow profile neither holds the catalog nor other profile results. + const worker = async () => { + while (active() && next < profiles.length) { + const profile = profiles[next++]; + let observed: LarkApp; + try { + const rows = await fetchLarkApps({verify: true, appRef: profile.app_ref, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(7000)])}); + if (rows.length !== 1 || rows[0].app_ref !== profile.app_ref) throw new Error("Lark profile changed"); + observed = rows[0].health_error_code === "lark_app_unverified" + ? {...rows[0], health_error_code: "lark_app_verification_failed"} : rows[0]; + } catch { + observed = {...profile, ready: false, reply_ready: false, health_error_code: "lark_app_verification_failed"}; + } + if (active()) setApps(items => items.map(item => item.app_ref === profile.app_ref ? observed : item)); + } + }; + void Promise.all(Array.from({length: Math.min(4, profiles.length)}, worker)); + } catch (cause) {if (active()) setError(cause);} + finally {if (active()) setLoading(false);} + }, []); + useEffect(() => {void refresh(); return () => current.current?.abort();}, [refresh]); + return {apps, loading, error, refresh}; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index ccece53190..c9f2a5208d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -15,7 +15,7 @@ type Job = {status: string; attempt?: number; analysis_wall_seconds?: number | n type Pack = ResearchProgressPack & {source_count: number; analysis_key: string; method_version: string; config: {model: string; effort: string; service_tier: string}; availability: string; reason_code: string | null; objective?: string; episodes?: TrajectoryEpisode[]; job?: Job | null}; -type Snapshot = {ok: true; catalog: {availability: string; reason_code: string | null; default_pack_id: string | null; packs: Pack[]}; pack: Pack | null}; +type Snapshot = {ok: true; execution_enabled?: boolean; catalog: {availability: string; reason_code: string | null; default_pack_id: string | null; packs: Pack[]}; pack: Pack | null}; type Citation = AssessmentCitation & {artifact: string; text: string; applicability: string}; const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); const locationFor = (goal: string) => { @@ -117,23 +117,23 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean requestAnimationFrame(() => {panel.current?.focus({preventScroll: true}); panel.current?.scrollIntoView({block: "start"});}); }; const execute = async (retry = false) => { - if (readSourceUrl) return; + if (readSourceUrl && snapshot?.execution_enabled !== true) return; if (!pack || pack.availability !== "available" || submitting.current) return; show(); submitting.current = true; setPending(true); setError(""); const current = generation.current; try { - await requestJson(endpoint, {method: "POST", body: JSON.stringify({goal_id: goalId, pack_id: pack.pack_id, analysis_key: pack.analysis_key, retry})}); + await requestJson(sourceReadApiUrl(readSourceUrl, endpoint), {method: "POST", body: JSON.stringify({goal_id: goalId, pack_id: pack.pack_id, analysis_key: pack.analysis_key, retry})}); if (current === generation.current) await read(pack.pack_id); } catch (e) {if (current === generation.current) {setError(errorText(e)); void read(pack.pack_id).catch(() => undefined);}} finally {if (current === generation.current) {submitting.current = false; setPending(false);}} }; const cancel = async () => { - if (readSourceUrl) return; + if (readSourceUrl && snapshot?.execution_enabled !== true) return; if (!pack || cancelPending) return; const current = generation.current; setCancelPending(true); try { - await requestJson(`${endpoint}/cancel`, {method: "POST", body: JSON.stringify({goal_id: goalId, pack_id: pack.pack_id, analysis_key: pack.analysis_key})}); + await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}/cancel`), {method: "POST", body: JSON.stringify({goal_id: goalId, pack_id: pack.pack_id, analysis_key: pack.analysis_key})}); if (current === generation.current) await read(pack.pack_id); } catch (e) {if (current === generation.current) setError(errorText(e));} finally {if (current === generation.current) setCancelPending(false);} @@ -150,7 +150,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean finally {if (current === citeGeneration.current) setCitationBusy(false);} }; const closeCitation = () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); requestAnimationFrame(() => sourceTrigger.current?.focus());}; - return {readOnly: Boolean(readSourceUrl), citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, + return {readOnly: Boolean(readSourceUrl) && snapshot?.execution_enabled !== true, citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, close: () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)}; } type Controller = ReturnType; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index bcd8ca49ef..06f9b50df0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -840,6 +840,9 @@ const en = { "lark.mentionsOnly": "Mentions only", "lark.needsMessagePermissions": "Needs message permissions", "lark.needsSetup": "Needs setup", + "lark.verificationPending": "Checking…", + "lark.verificationFailed": "Not verified", + "lark.retryVerification": "Refresh profiles and retry verification", "lark.newApp": "New Lark App", "lark.noAgentConfigured": "No Agent configured", "lark.noConnections": "No matching connections.", @@ -2431,6 +2434,9 @@ const zhCN: Record = { "lark.mentionsOnly": "仅提及消息", "lark.needsMessagePermissions": "需要消息权限", "lark.needsSetup": "需要设置", + "lark.verificationPending": "正在核验…", + "lark.verificationFailed": "尚未核验", + "lark.retryVerification": "刷新配置并重试核验", "lark.newApp": "新建 Lark App", "lark.noAgentConfigured": "未配置 Agent", "lark.noConnections": "暂无匹配的连接。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx index 80d8ef99cd..a17d91c520 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx @@ -1,4 +1,5 @@ import {PrivateConversationPanel} from "./private-conversation-panel"; +import {useLarkApps} from "../../data/use-lark-apps"; import { useEffect, useMemo, useRef, useState } from "react"; import { Bot, @@ -7,6 +8,7 @@ import { Loader2, MessageSquareText, Plus, + RefreshCw, Search, Settings2, Unlink, @@ -19,11 +21,9 @@ import { connectLarkGoalTopic, disconnectLarkGoalTopic, fetchLarkAppSetup, - fetchLarkApps, fetchLarkConnections, fetchLarkGroupChats, startLarkAppSetup, - type LarkApp, type LarkAppSetup, type LarkCaptureScope, type LarkGoalConnection, @@ -182,6 +182,7 @@ function larkErrorMessage(cause: unknown, fallback: string, t: WorkspaceTranslat lark_message_permissions_required: t("lark.error.messagePermissions"), lark_app_required: t("lark.error.appRequired"), invalid_lark_app: t("lark.error.invalidApp"), + lark_configuration_unavailable: t("lark.error.configuration"), lark_group_lookup_failed: t("lark.error.groupLookup"), provider_api_failed: t("lark.error.provider"), }; @@ -209,7 +210,7 @@ export function LarkSettingsPage({ }) { const { t } = useWorkspaceI18n(); const [tab, setTab] = useState("connections"); - const [apps, setApps] = useState([]); + const {apps, loading: appsLoading, error: appsError, refresh: refreshApps} = useLarkApps(); const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -247,31 +248,37 @@ export function LarkSettingsPage({ const openedSetupUrl = useRef(null); const desktopHost = Boolean((window as Window & { __TAURI__?: unknown }).__TAURI__); const focusedGoalOpened = useRef(false); + const connectionRead = useRef(null); async function refresh() { + connectionRead.current?.abort(); + const controller = new AbortController(); + connectionRead.current = controller; setLoading(true); setError(null); try { - const [nextApps, nextConnections] = await Promise.all([ - fetchLarkApps(), - fetchLarkConnections(), - ]); - setApps(nextApps); + const nextConnections = await fetchLarkConnections(AbortSignal.any([controller.signal, AbortSignal.timeout(7000)])); + if (controller.signal.aborted) return; setConnections(nextConnections); setCliUnavailable(false); - setAppRef((current) => current || nextApps.find((app) => app.reply_ready)?.app_ref || nextApps.find((app) => app.ready)?.app_ref || nextApps[0]?.app_ref || ""); } catch (cause) { + if (controller.signal.aborted) return; setCliUnavailable(larkCliUnavailable(cause)); setError(larkErrorMessage(cause, t("lark.error.configuration"), t)); } finally { - setLoading(false); + if (!controller.signal.aborted) setLoading(false); } } useEffect(() => { void refresh(); + return () => connectionRead.current?.abort(); }, []); + useEffect(() => { + setAppRef(current => apps.some(app => app.app_ref === current) ? current : apps.find(app => app.ready)?.app_ref ?? apps[0]?.app_ref ?? ""); + }, [apps]); + useEffect(() => { if (!focusGoalConnection || loading || focusedGoalOpened.current || !initialGoalId) return; focusedGoalOpened.current = true; @@ -334,6 +341,7 @@ export function LarkSettingsPage({ } } if (snapshot.status === "ready") { + void refreshApps(); await refresh(); setAppRef(snapshot.app_ref); setAgentAppRefs({}); @@ -439,7 +447,7 @@ export function LarkSettingsPage({ function openSetup() { // Every setup entry (toolbar, connection dialog) meets the same fact: // without lark-cli the setup can only fail until LoopX restarts. - if (cliUnavailable) return; + if (cliUnavailable || larkCliUnavailable(appsError)) return; setSetupSnapshot(null); setSetupError(null); openedSetupUrl.current = null; @@ -545,27 +553,28 @@ export function LarkSettingsPage({ )} - + {tab === "connections" ? app.ready && app.app_ref !== "default")} onOpenOriginal={onOpenOriginalConversation} /> : null} {error ?

{error}

: null} + {appsError ?

{larkErrorMessage(appsError, t("lark.error.configuration"), t)}

: null} {loading ?
{t("lark.loading")}
: null} - {!loading && tab === "apps" ? ( + {tab === "apps" ? (
-
{t("lark.reusableApps", { count: apps.length })}
+
{t("lark.reusableApps", { count: apps.length })}
{apps.map((app) => (
{app.label}{app.brand} · lark-cli profile
- {app.reply_ready ? t("lark.autoReplyReady") : app.ready ? t("lark.needsMessagePermissions") : t("lark.needsSetup")} + {app.health_error_code === "lark_app_unverified" ? t("lark.verificationPending") : app.health_error_code === "lark_app_verification_failed" ? t("lark.verificationFailed") : app.reply_ready ? t("lark.autoReplyReady") : app.ready ? t("lark.needsMessagePermissions") : t("lark.needsSetup")}

{t("lark.goalConnections", { count: connections.filter((connection) => connection.app_ref === app.app_ref).length })}{app.ready && !app.reply_ready ? ` · ${t("lark.autoReplyUnavailable")}` : ""}

))} - {apps.length === 0 ?

{t("lark.noProfiles")}

: null} + {appsLoading ?

{t("lark.loading")}

: apps.length === 0 && !appsError ?

{t("lark.noProfiles")}

: null}
) : null} @@ -621,7 +630,7 @@ export function LarkSettingsPage({ {t("lark.editPreservesIdentity")} : <> - + {selectedApp?.ready && !selectedApp.reply_ready ?
{t("lark.appPermissions")}
: null} ))} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 5559def549..b190bc3c55 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -789,6 +789,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-lark-loading, .personal-lark-empty { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 130px; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); } .personal-lark-apps { max-width: 1180px; margin: 0 auto; } .personal-lark-app-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } +.personal-lark-app-actions { display: flex; align-items: center; gap: 8px; } .personal-lark-app-toolbar .personal-primary-action { width: auto; min-height: 40px; margin: 0; padding: 0 16px; } .personal-lark-app-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; } .personal-lark-app-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 18px; border: 1px solid var(--pw-line); border-radius: 15px; background: var(--pw-card); } 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 92dec92d91..11a6bef9d8 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 @@ -1,15 +1,14 @@ import {useEffect, useRef, useState} from "react"; import {connectPrivateConversation, disconnectPrivateConversation, fetchPrivateConversations, - changePrivateAgentTarget, changeOwnerNotifications, fetchChatProjects, fetchChatCapabilities, fetchLarkApps, fetchLarkGroupChats, type PrivateConversation, + changePrivateAgentTarget, changeOwnerNotifications, fetchChatProjects, fetchChatCapabilities, fetchLarkGroupChats, type PrivateConversation, discoverBoundChatSession, type BoundConversationTarget, type ChatProject, type LarkApp, type PrivateConversationStopRecovery} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; import "./private-conversation.css"; -export function PrivateConversationPanel({ onOpenOriginal }: { onOpenOriginal?: (target: BoundConversationTarget) => void }) { +export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[]; onOpenOriginal?: (target: BoundConversationTarget) => void}) { const {locale} = useWorkspaceI18n(); const zh = locale === "zh-CN"; - const [apps, setApps] = useState([]); const [projects, setProjects] = useState([]); const [executors, setExecutors] = useState([]); const [rows, setRows] = useState([]); @@ -52,7 +51,6 @@ export function PrivateConversationPanel({ onOpenOriginal }: { onOpenOriginal?: let active = true; const failed = (error: unknown) => {if (active) setError(String(error));}; // Existing conversations do not wait for credential/configuration catalogs. - void fetchLarkApps().then(apps => {if (active) setApps(apps.filter(app => app.ready && app.app_ref !== "default"));}).catch(failed); void fetchChatProjects().then(projects => {if (active) { setProjects(projects.projects); if (projects.projects.length === 1) setProject(projects.projects[0].project_ref); @@ -205,7 +203,7 @@ export function PrivateConversationPanel({ onOpenOriginal }: { onOpenOriginal?: : null} {role === "project" && effectiveProjectGrant === "workspace_write" ?

{zh ? "此 App 可按你的指令编辑选定工作区;不提高已直连 Agent 的原宿主权限。更改权限会建立新绑定和新会话,旧会话不会自动提升权限,已有 Agent 直连需重新授权。" : "This App may edit the selected workspace on your instruction; attached Agents retain their original host permissions. Changing access creates a new binding and Session. Existing Chat does not gain access and Agent targets require new authorization."}

: null} -

{zh ? "发送文字、图片或图文消息开始;后续消息进入原会话队列。/status 查看工作区、角色与持久排队状态,/help 查看用法与解绑入口,/stop 停止当前聊天执行,/new 开启新会话。文件、音视频、附在控制命令或已选择 Agent 上的图片会明确提示暂不支持。" : "Send text, images or image/text posts to begin; follow-ups queue in the same Session. /status shows the workspace, role and durable queue, /help explains commands and where to unbind, /stop stops the current Chat Turn, /new starts a new conversation. Files, audio/video, and images sent with control commands or to a selected attached Agent receive an explicit unsupported response."}

diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 6054750162..ea409513be 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -3654,7 +3654,9 @@ export function DashboardPage() { }, [exampleModeRequested, requestedStatusUrl, search.statusUrl, search.rootGoalId, source.kind, source.label]); useEffect(() => { - if (isLoading && search.rootGoalId) return; + // A source transition can still expose the previous source's Goal rows. + // Reconcile only its committed URL, including during browser Back/Forward. + if (isLoading || (source.kind === "url" && search.statusUrl && search.statusUrl !== source.label)) return; if (search.statusUrl && source.kind === "example") { return; } @@ -3662,6 +3664,7 @@ export function DashboardPage() { if (goalRows.length === 0) { if (search.goalId) { void navigate({ + replace: true, search: (current) => ({ ...current, goalId: "", @@ -3673,13 +3676,14 @@ export function DashboardPage() { if (search.goalId && !goalIds.has(search.goalId) && goalArchiveLoadState.phase !== "loading") { void navigate({ + replace: true, search: (current) => ({ ...current, goalId: "", }), }); } - }, [goalArchiveLoadState.phase, goalRows, isLoading, navigate, search.goalId, search.rootGoalId, search.statusUrl, source.kind]); + }, [goalArchiveLoadState.phase, goalRows, isLoading, navigate, search.goalId, search.rootGoalId, search.statusUrl, source.kind, source.label]); useEffect(() => { if (!progress || isLoading || !search.goalId || source.kind !== "url") return; diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index e3b01a16a5..333b977a27 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -4,7 +4,7 @@ - Supersedes / closes: none - Proposed by: LoopX maintainers - Date: 2026-08-15 -- Last revised: 2026-10-16 +- Last revised: 2026-10-19 - Scope: an incremental, replacement-first migration of the LoopX control-plane core from Python to TypeScript without maintaining two semantic implementations @@ -17,6 +17,25 @@ ### Product conversations and asynchronous inbox +**Receiver Inbox read transaction (2026-10-19).** +`loopx/control_plane/collaboration/inbox_read.ts` now composes local source scans, pagination, +Goal-instance admission, receiver and peer-return read receipts, and unfinished +delegation context through the existing typed owners. Turn-start observation +uses a read-only request. Receiver reads without a pending Host provider commit +their local read receipts in that request. Actual Core Todo, workspace-input and +operation-ticket providers remain explicit Python IO; finalization reacquires +the Goal lifetime guard and rereads entries under the shared request fence. +Reading still grants neither adoption, consumption nor execution authority. +The old Python scan/composition loops and five fine-grained RPC registrations +are removed. The legacy Inbox read imports and tracking/presentation receipt +views retain thin native facades; their exit is migration of those remaining +Python consumers. Existing return-display file IO retains its Python adapter +and the shared persisted delivery vocabulary. Large pages reuse the shared +private snapshot transport. Normal runtime IPC, installed +HTTP caller parity and complete default-SQLite CLI performance remain separate +qualification gates. This source cutover does not certify R1–R3 or the complete +asynchronous collaboration lifecycle. + R1–R3 also consume the typed kernel through the App, not only CLI settlement. The [App/inbox integration plan](app-conversation-and-async-inbox-v0.md#ts-and-generic-async-inbox-migrate-with-the-user-path) orders caller fixes, characterization, one complete async lifecycle cutover, diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index eb238403b9..27abbd7992 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -4,7 +4,7 @@ - 替代 / 关闭:无 - Proposed by:LoopX maintainers - Date:2026-08-15 -- Last revised:2026-10-16 +- Last revised:2026-10-19 - Scope:LoopX 控制面核心从 Python 到 TypeScript 的增量、replacement-first 迁移;不长期维护两份语义实现 - Tracking issue:[#3225](https://github.com/loopx-project/loopx/issues/3225) @@ -17,6 +17,21 @@ ### 产品对话与通用异步 inbox +**接收方 Inbox 读取事务(2026-10-19)。** +`loopx/control_plane/collaboration/inbox_read.ts` 现在通过既有 typed owner 组合本地源扫描、分页、 +Goal-instance 准入、接收方及 peer-return 读取回执和未完成委派上下文。 +Turn-start 观察使用只读请求。没有待处理 Host provider 的接收读取,在同一个请求中 +提交本地读取回执。实际 Core Todo、工作区输入和操作票据 provider 仍是显式 Python IO; +finalize 重新取得 Goal lifetime guard,并在共享 request fence 内重读 entry。 +读取仍不授予 adoption、consumption 或执行权限。 +旧 Python 扫描与组合循环及五个细粒度 RPC 注册已删除。 +旧 Inbox 读取 import 和 tracking/presentation 回执视图保留薄 native facade; +这些层在剩余 Python 消费者迁移后退出。既有 return 展示文件 IO 保留 Python adapter, +复用同一份持久化 delivery 词汇。大页面复用共享私有 snapshot 传输。 +正常 runtime IPC、已安装 HTTP 调用者一致性、 +完整默认 SQLite CLI 性能仍是独立资格门。本次源码切换不证明 R1–R3 或完整异步协作 +生命周期已通过验收。 + R1–R3 的 TS 消费者包括 App 产品路径,不只 CLI 结算。 [App/inbox 融合设计](app-conversation-and-async-inbox-v0.md#ts-and-generic-async-inbox-migrate-with-the-user-path) 按调用者修复、行为刻画、完整异步生命周期切换、真实 adapter 恢复与旧代码删除推进。 diff --git a/docs/development/assets/runtime-source-analysis/analysis-desktop.png b/docs/development/assets/runtime-source-analysis/analysis-desktop.png new file mode 100644 index 0000000000..1b60359e5e Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/analysis-desktop.png differ diff --git a/docs/development/assets/runtime-source-analysis/analysis-phone-detail.png b/docs/development/assets/runtime-source-analysis/analysis-phone-detail.png new file mode 100644 index 0000000000..b0f0f14172 Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/analysis-phone-detail.png differ diff --git a/docs/development/assets/runtime-source-analysis/analysis-phone.png b/docs/development/assets/runtime-source-analysis/analysis-phone.png new file mode 100644 index 0000000000..c63a584bef Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/analysis-phone.png differ diff --git a/docs/development/assets/runtime-source-analysis/before-desktop.png b/docs/development/assets/runtime-source-analysis/before-desktop.png new file mode 100644 index 0000000000..caed23805b Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/before-desktop.png differ diff --git a/docs/development/assets/runtime-source-analysis/facts-desktop.png b/docs/development/assets/runtime-source-analysis/facts-desktop.png new file mode 100644 index 0000000000..c8411865cf Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/facts-desktop.png differ diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index b2bccb766c..efc619a005 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -78,9 +78,12 @@ are withheld on failure; retry targets the failed selection, not the last successful reader. Switching prepared readers clears source-local question and analysis selections; Back restores the previous reader's URL and its own selection. -All mutation endpoints are denied. A read-only source does not imply an +By default all mutation endpoints are denied. A read-only source does not imply an analysis pack, model execution, adoption or validated benefit; analysis remains -unavailable until its existing owner binds the appropriate source pack. +unavailable until its existing owner binds the appropriate source pack and the +operator explicitly enables [local analysis](../reference/trajectory-assessment.md#existing-isolated-goal-sources). +That separate local provider uses the original source Goal id and a +source-namespace-isolated store; it creates no Goal mirror or source mutations. The adapter uses one local Python process with one HTTP listener/thread per prepared source. Cache misses invoke the source's native status CLI or bounded log read; no lower CPU, process or IO cost is implied. Local host/container diff --git a/docs/reference/protocols/quota-planning-horizon-v0.md b/docs/reference/protocols/quota-planning-horizon-v0.md index 07432d40b8..87239f3c0e 100644 --- a/docs/reference/protocols/quota-planning-horizon-v0.md +++ b/docs/reference/protocols/quota-planning-horizon-v0.md @@ -79,6 +79,30 @@ context independently. TurnEnvelope, stride, execution permission and quota are not enabled by this setting. Context/signature changes are observable input changes, not evidence of improved model decisions. +## Agent Decision Authority + +Use the horizon to choose a coherent outcome across related work, considering +current user direction, evidence, dependencies, expected value, switching cost +and remaining budget. Priority ranks the default recommendation; a ready +higher-priority deferred successor does not veto an explicitly chosen eligible +open Todo. It stays visible for planning and retains its lifecycle state. +If choosing another route matters to the plan, record why in the existing Todo +note or vision/path-delta writeback. Do not manufacture priority changes or +extra-small Todos merely to satisfy the ordering. + +The horizon itself grants no execution authority. Select through the existing +explicit selection/re-entry flow, which still checks scope, capabilities, +claims, resume conditions, workspace and real blocking lanes. A successor link +alone is not a dependency. A selected deferred Todo still requires its resume +transition before execution. + +A committed Turn binds one execution identity, not the Todo until completion. +At an appropriate checkpoint, preserve progress, settle that original Turn and +release its lease before selecting another Todo in a new Turn. Re-entry of an +unsettled Turn preserves its original identity; it cannot silently account +unrelated work or restart a completed effect. Unbound suggestions and pending +choices have no such execution identity to protect. + ## Shape ```json diff --git a/docs/reference/trajectory-assessment.md b/docs/reference/trajectory-assessment.md index 5756fb795d..2541d64fde 100644 --- a/docs/reference/trajectory-assessment.md +++ b/docs/reference/trajectory-assessment.md @@ -1,9 +1,9 @@ # Evidence-bound trajectory assessment -Status: an opt-in analysis producer, source-verified pack registry and metric -protocol. The packaged Goal-view consumer and independent evaluator calibration -remain separate acceptance work. -This implements the read-only producer slice of the S5/S11 +Status: source-verified trajectory packs, an opt-in analysis producer and the +packaged Goal reader. Independent evaluator calibration and scientific +treatment-effect qualification remain separate acceptance work. +These use the S5/S11 [research episode assessment](../architecture/rfcs/long-horizon-harness-benchmark-research-program-v0.md#evidence-based-episode-evaluation-proposed-refinement). It does not change solver prompts, benchmark scoring, checkpoints or research budgets. @@ -414,6 +414,56 @@ settings or arbitrary commands. Native utility remains unknown without its measured projection. Agent judgments are uncalibrated and do not establish scientific gains or a whole-run success rate. +### Existing isolated Goal sources + +The prepared runtime workspace can use this same reader and analysis provider +without copying an execution Goal into the local registry. Start its ordinary +entry with an explicit, local analysis store: + +```sh +python -m loopx.presentation.runtime_workspace --sources sources.json \ + --analysis-runtime-root .local/source-analysis +``` + +This option enables **local** Codex analysis on explicit button clicks. It uses +the local account and incurs separate analysis usage. Omitting it preserves +the original read-only behavior: no analysis jobs or mutations are accepted. +It grants no source-Goal lifecycle, conversation, scoring or solver writes. +Stop and restart the reader without the option to disable local execution; +historical packs remain on disk. Cancel running analysis through its existing +action before stopping the server. + +The trusted curator registers authorized inputs through the existing +`trajectory-pack-register` command. For each source, use runtime root +`/goal-sources/` and the **original +source Goal id** as `--goal-id`. The input's `scope.goal_id` must match that id. +The namespace is returned by `/api/goal-source`; it binds the prepared physical +source, including its pinned container and registry. Keep the native run, +checkpoint, evidence revisions and observation window explicit in the pack. +Registration creates analysis artifacts only; it does not create or reparent +a Goal. Parent/child organization remains whatever the original authority +actually records. + +The packaged Goal overview then shows registered factual measurements and +citations without a model call. **Deep analysis** explicitly invokes the same +toolkit provider, claim, retry, cancellation and result validation as the local +Goal reader. Its selector, refresh and browser history retain the physical +source. Analysis reads and writes require its namespace and current registered +Goal membership; another source with the same Goal name cannot read or execute +those packs. Provider operations re-read the original source before admission; +ordinary display reads retain their short cache. Removed or unavailable sources +fail visibly and retry against the original selection. Changed evidence retains +its historical result and prevents +new execution until its source is requalified. + +The following packaged reader uses a synthetic Goal, evidence and provider +response. Its example values do not qualify model judgment or benchmark gains. + +![Original source Goal with local factual measurements](../development/assets/runtime-source-analysis/facts-desktop.png) + +[After explicit analysis](../development/assets/runtime-source-analysis/analysis-desktop.png) +and the [narrow-screen metric reader](../development/assets/runtime-source-analysis/analysis-phone-detail.png). + The synthetic packaged example below illustrates the reader and its scope controls; its numbers are test data, not benchmark findings. Real registered checkpoint reads are qualified separately from this presentation example. diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index 55cd87f8ec..9667d0d787 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -1292,7 +1292,12 @@ the legal choice boundary separate from the displayed suggestions; `suggestions_exhaustive=false` makes that distinction machine-readable. `selection_policy.decision_owner=agent` and `recommendation_role=default_not_binding` make the priority order advisory at -this boundary rather than silently binding the first Todo. +this boundary rather than silently binding the first Todo. A ready deferred +higher-priority successor remains planning context, but neither removes an +otherwise eligible open alternative nor vetoes its explicit selection. Default +successor-replan recommendations remain available; the selected deferred Todo's +own resume condition still applies. See the +[planning-horizon decision boundary](reference/protocols/quota-planning-horizon-v0.md#agent-decision-authority). Version 2 adds the optional `continuation_hint` field to each `suggested_actions[]` item so an agent can see the candidate's next execution diff --git a/examples/personal-workspace-browser/evidence/source-history-return/before-desktop.png b/examples/personal-workspace-browser/evidence/source-history-return/before-desktop.png new file mode 100644 index 0000000000..bdbb1f3760 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/source-history-return/before-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/source-history-return/desktop.png b/examples/personal-workspace-browser/evidence/source-history-return/desktop.png new file mode 100644 index 0000000000..1421572f77 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/source-history-return/desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/source-history-return/phone.png b/examples/personal-workspace-browser/evidence/source-history-return/phone.png new file mode 100644 index 0000000000..e3752c3667 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/source-history-return/phone.png differ diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 87d87ebf3c..7edd02b7c0 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1479,13 +1479,14 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu return; } if (url.pathname === "/api/chat/lark/apps") { + const apps = [ + { active: true, app_ref: "mew", brand: "feishu", label: "LoopX Mew", ready: true, reply_ready: true }, + { active: true, app_ref: "mew-research", brand: "feishu", label: "LoopX Research", ready: true, reply_ready: true }, + ].filter(app => !url.searchParams.has("app_ref") || app.app_ref === url.searchParams.get("app_ref")); await route.fulfill({ contentType: "application/json", json: { - ok: true, - schema_version: "loopx_lark_apps_v0", - apps: [ - { active: true, app_ref: "mew", brand: "feishu", label: "LoopX Mew", ready: true, reply_ready: true }, - { active: true, app_ref: "mew-research", brand: "feishu", label: "LoopX Research", ready: true, reply_ready: true }, - ], + ok: true, schema_version: "loopx_lark_apps_v0", + apps: apps.map(app => url.searchParams.get("verify") === "false" + ? {...app, ready: false, reply_ready: false, health_error_code: "lark_app_unverified"} : app), }, status: 200 }); return; } diff --git a/examples/personal-workspace-browser/progressive-loading.mjs b/examples/personal-workspace-browser/progressive-loading.mjs index 1d3c869a69..c8c6af02e4 100644 --- a/examples/personal-workspace-browser/progressive-loading.mjs +++ b/examples/personal-workspace-browser/progressive-loading.mjs @@ -1,4 +1,64 @@ import { openWorkspacePage } from "./scenario-context.mjs"; +import {resolve} from "node:path"; +import {outputDir} from "./fixture.mjs"; + +async function verifyLarkLoading(browser, url, collectCoverage) { + let releaseSlow; + const slow = new Promise(resolve => {releaseSlow = resolve;}); + let revision = 1, catalogs = 0; + const probes = []; + const context = await openWorkspacePage(browser, url, {collectCoverage, beforeGoto: async (_api, page) => { + await page.route("**/api/chat/lark/apps?*", async route => { + const query = new URL(route.request().url()).searchParams; + const current = revision; + const refs = ["fast", "slow", "failed"]; + if (query.get("verify") === "false") catalogs++; + else { + probes.push(query.get("app_ref")); + if (query.get("app_ref") === "slow" && current === 1) await slow; + if (query.get("app_ref") === "failed" && current === 1) { + await route.fulfill({status: 503, json: {ok: false, error: "Synthetic verification outage"}}); return; + } + } + const verifying = query.get("verify") === "false"; + await route.fulfill({json: {ok: true, apps: refs.filter(ref => verifying || ref === query.get("app_ref")).map(ref => ({ + app_ref: ref, active: true, brand: "feishu", label: `${ref} ${current}`, + ready: !verifying, reply_ready: !verifying, + health_error_code: verifying ? "lark_app_unverified" : null, + }))}}); + }); + }}); + const {page} = context; + async function openSettings() { + const menu = page.locator(".personal-mobile-menu"); + if (await menu.isVisible()) await menu.click(); + await page.getByRole("button", {name: "设置", exact: true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "Lark", exact: true}).click(); + await page.locator(".personal-lark-tabs").getByRole("button", {name: /^Lark Apps/u}).click(); + } + try { + await openSettings(); + const fast = page.locator(".personal-lark-app-card").filter({hasText: "fast 1"}); + await fast.getByText("自动回复可用", {exact: true}).waitFor(); + await page.locator(".personal-lark-app-card").filter({hasText: "slow 1"}).getByText("正在核验…", {exact: true}).waitFor(); + await page.locator(".personal-lark-app-card").filter({hasText: "failed 1"}).getByText("尚未核验", {exact: true}).waitFor(); + if (catalogs !== 1 || probes.length !== 3) throw new Error("Settings panels duplicated the profile catalog or verification"); + await page.screenshot({path: resolve(outputDir, "lark-progressive-desktop.png"), animations: "disabled"}); + // Explicit refresh aborts the old generation; a late healthy result cannot + // restore an old profile after the configuration changed. + revision = 2; + await page.getByRole("button", {name: "刷新配置并重试核验", exact: true}).click(); + await page.locator(".personal-lark-app-card").filter({hasText: "slow 2"}).getByText("自动回复可用", {exact: true}).waitFor(); + releaseSlow(); + await page.waitForTimeout(200); + if (catalogs !== 2 || probes.length !== 6 || await page.getByText("slow 1", {exact: true}).count()) throw new Error("Late verification escaped its settings generation"); + await page.setViewportSize({width: 390, height: 844}); + if (await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error("Lark configuration overflows at 390px"); + await page.screenshot({path: resolve(outputDir, "lark-progressive-narrow.png"), animations: "disabled"}); + if (context.errors.some(error => !error.startsWith("Failed to load resource:"))) throw new Error(context.errors.join(" | ")); + return await context.close(); + } catch (error) {releaseSlow(); await context.close(); throw error;} +} // One Goal's lifecycle action must not send the rest of the workspace back to // the loading lane. The directory entry is the cheap signal for "this Goal did @@ -75,6 +135,7 @@ export const progressiveLoadingScenario = { // dev-server resource status is not a client-side exception. const scriptErrors = pageErrors.filter((message) => !message.startsWith("Failed to load resource")); if (scriptErrors.length) throw new Error(`Progressive reconciliation raised page errors: ${scriptErrors.join(" | ")}`); + coverageEntries.push(...await verifyLarkLoading(browser, url, collectCoverage)); return { coverageEntries, note: "progressive loading reads the workspace directory once and then one Goal at a time; a single Goal's pause and resume re-read no peer, never re-enters the loading lane and keeps every peer card on the board", diff --git a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs index 80aec64612..425af3fbca 100644 --- a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs @@ -19,7 +19,8 @@ from loopx.control_plane.status.goal_source import GoalSource from loopx.capabilities.explore.result_log import build_explore_node_event, build_explore_edge_event class Source(GoalSource): def __init__(self, name): - super().__init__(name,name,'/registry','/runtime','/cli','/python','/bin','local','host','same-goal') + goal_id = 'other-goal' if name == 'Distinct' else 'same-goal' + super().__init__(name,name,'/registry','/runtime','/cli','/python','/bin','local','host',goal_id) def read_status(self, goal_id=None): self.check_access(goal_id) if pathlib.Path(sys.argv[1]).exists() and self.id == 'Fresh': raise OSError('unavailable fixture') @@ -37,7 +38,7 @@ class Source(GoalSource): events += [build_explore_edge_event(goal_id=self.goal_id,from_node='route'+str(i),to_node='route'+str(i+1),edge_type='depends_on',recorded_at='2026-01-01T23:%02d:00Z'%(i*30)) for i in range(2)] return events assets=pathlib.Path(sys.argv[2]) -servers=[RuntimeWorkspaceServer(Source(n),assets) for n in ('Retained','Fresh')] +servers=[RuntimeWorkspaceServer(Source(n),assets) for n in ('Retained','Fresh','Distinct')] catalog=[dict(id=s.source.id,label=s.source.label,status_url=s.status_url) for s in servers] for s in servers: s.catalog=catalog @@ -92,6 +93,36 @@ try { await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); await page.goBack(); await page.getByRole("heading",{name:"Retained research",exact:true}).waitFor(); + // Different Goal ids exercise automatic selection cleanup as well as the + // source-local node fence above. Cleanup must not rewrite the old source's + // entry or add an extra entry that traps Back/Forward on the new source. + await page.getByLabel("选择控制面来源",{exact:true}).click(); + await page.getByRole("option",{name:"Distinct",exact:true}).click(); + await page.getByRole("button",{name:/Distinct research/}).first().waitFor(); + await page.waitForFunction(()=>new URL(location.href).searchParams.get("goalId")===""); + await page.goBack(); + await page.getByRole("heading",{name:"Retained research",exact:true}).waitFor({timeout:10000}); + assert.equal(new URL(page.url()).searchParams.get("goalId"),"same-goal"); + assert.equal(new URL(page.url()).searchParams.get("research_node"),"route0"); + await page.screenshot({path:resolve(output,"source-return-desktop.png")}); + await page.goForward(); + await page.getByRole("button",{name:/Distinct research/}).first().waitFor(); + await page.getByRole("button",{name:/Distinct research/}).first().click(); + await page.getByRole("heading",{name:"Distinct research",exact:true}).waitFor(); + assert.equal(new URL(page.url()).searchParams.get("goalId"),"other-goal"); + await page.reload(); + await page.getByRole("heading",{name:"Distinct research",exact:true}).waitFor(); + await page.getByLabel("选择控制面来源",{exact:true}).click(); + await page.getByRole("option",{name:"Retained",exact:true}).click(); + await page.getByRole("button",{name:/Retained research/}).first().waitFor(); + await page.waitForFunction(()=>new URL(location.href).searchParams.get("goalId")===""); + await page.goBack(); + await page.getByRole("heading",{name:"Distinct research",exact:true}).waitFor({timeout:10000}); + assert.equal(new URL(page.url()).searchParams.get("goalId"),"other-goal"); + await page.goForward(); + await page.getByRole("button",{name:/Retained research/}).first().waitFor(); + await page.getByRole("button",{name:/Retained research/}).first().click(); + await page.getByRole("heading",{name:"Retained research",exact:true}).waitFor(); await writeFile(failFile, "unavailable"); for (let attempt=0;attempt<40;attempt++) { if ((await fetch(catalog[1].status_url)).status === 503) break; diff --git a/examples/personal-workspace-browser/screenshots/lark-progressive/desktop.png b/examples/personal-workspace-browser/screenshots/lark-progressive/desktop.png new file mode 100644 index 0000000000..a270a938cb Binary files /dev/null and b/examples/personal-workspace-browser/screenshots/lark-progressive/desktop.png differ diff --git a/examples/personal-workspace-browser/screenshots/lark-progressive/narrow.png b/examples/personal-workspace-browser/screenshots/lark-progressive/narrow.png new file mode 100644 index 0000000000..40c9aa423a Binary files /dev/null and b/examples/personal-workspace-browser/screenshots/lark-progressive/narrow.png differ diff --git a/loopx/capabilities/manager_context/README.md b/loopx/capabilities/manager_context/README.md index b621a77a66..dd5a359fd6 100644 --- a/loopx/capabilities/manager_context/README.md +++ b/loopx/capabilities/manager_context/README.md @@ -164,6 +164,20 @@ LoopX quota/heartbeat Turn-start path; arbitrary host Turns that do not invoke that path are outside this integration. Frontend/Lark conversations retain their existing request and return views; no new screen or setting is introduced. +The local Inbox read transaction now lives in +`loopx/control_plane/collaboration/inbox_read.ts`. It reuses the typed receipt, +Goal-instance, request-revision and agenda owners inside one runtime request. +The Turn hook remains read-only. CLI/MCP reads record the supplied requests and +peer-return read receipts; they do not adopt requests or consume peer results. +Python releases the initial Goal lifetime guard for provider enrichment. +Core Todo and workspace inputs retain their actual IO adapters and provider +fences. The Host reacquires the Goal guard before native finalization. +Operation-ticket observations retain their existing provider. Complete large +pages use the shared private snapshot transport. Existing return-display file +IO remains in its Python adapter. Existing storage addresses, source records and public +response shapes remain compatible. Normal runtime IPC, installed HTTP journeys +and complete CLI performance require separate qualification. + Read and record a decision through the installed CLI: ```sh diff --git a/loopx/capabilities/manager_context/__init__.py b/loopx/capabilities/manager_context/__init__.py index 04bd9ec2c7..218d437dd3 100644 --- a/loopx/capabilities/manager_context/__init__.py +++ b/loopx/capabilities/manager_context/__init__.py @@ -238,22 +238,20 @@ def turn_start_hook( def produce(): nonlocal snapshot try: - from ...control_plane.collaboration.turn_context_observation import read_delegation_turn_context + from ...control_plane.collaboration.inbox_read import read_inbox_transaction + from ...control_plane.collaboration.inbox import _pending_result - snapshot = read_delegation_turn_context(runtime_root, registry_path, goal_id, agent_id, - read_command=command) - agenda = snapshot["unfinished_delegations"] with collaboration_goal_scope( - registry_path, - goal_id=goal_id, - agents=(agent_id,), + registry_path, goal_id=goal_id, agents=(agent_id,), ) as goal_scope: - inbox = pending( - runtime_root, - goal_id, - agent_id, - scope=goal_scope, - ) + observed = read_inbox_transaction(runtime_root, { + "operation": "turn_start", "goal_id": goal_id, "agent_id": agent_id, + "read_command": command, + }, scope=goal_scope) + observed.update(goal_id=goal_id, agent_id=agent_id) + inbox = _pending_result(runtime_root, observed, goal_scope, None) + snapshot = observed["context"] + agenda = snapshot["unfinished_delegations"] count = ( max(len(inbox["items"]), agenda["incoming_count"]) + max(len(inbox.get("peer_returns", {}).get("items", [])), agenda["outgoing_count"]) diff --git a/loopx/capabilities/native_chat/external_conversations.py b/loopx/capabilities/native_chat/external_conversations.py index 1326ba2dc0..6e6fb8bc6b 100644 --- a/loopx/capabilities/native_chat/external_conversations.py +++ b/loopx/capabilities/native_chat/external_conversations.py @@ -168,7 +168,7 @@ def _admit_prepared(self, path: Path, row: dict[str, Any], selected: dict[str, A controller = self.controller self.bindings.ensure_delivery_scope(selected) if not row.get("routing_recorded"): - choices = [item for item in self.pending() if item["binding_id"] == row["binding_id"] + choices = [item for item in self.pending() if item.get("binding_id") == row["binding_id"] and item["source"]["source_ref"] == row["source"]["source_ref"] and item.get("selection_recorded")] previous = max(choices, key=lambda item: item.get("selection_order", 0)) if choices else None @@ -262,7 +262,7 @@ def _admit_prepared(self, path: Path, row: dict[str, Any], selected: dict[str, A row["response"] = "当前没有已授权且可用的 Agent。请在本机 Chat → 设置 → Lark,为此 App 明确授权已有 attached Session;名称相似不会获得授权。" if not choices else "可选 Agent(使用确切命令):\n" + "\n".join(f"{item['agent_id']} · {item['goal_id']}\n/agent {item['target_ref']}" for item in choices) if row.get("selection_recorded"): row["selection_order"] = 1 + max((item.get("selection_order", 0) for item in self.pending() - if item["binding_id"] == row["binding_id"] and item["source"]["source_ref"] == row["source"]["source_ref"]), default=0) + if item.get("binding_id") == row["binding_id"] and item["source"]["source_ref"] == row["source"]["source_ref"]), default=0) row.update(status="command_completed", session_id=None, turn_id=None) elif operation == "steward_action": parsed = self.bindings._core("collaboration.steward.command", {"command": row["command"], "message": row["message"]}) @@ -385,8 +385,9 @@ def commission_evidence(self, session: dict[str, Any]) -> list[dict[str, Any]]: latest: dict[str, dict[str, Any]] = {} for row in sorted(self.pending(), key=lambda row: row.get("created_at", "")): resources = row.get("commission_resources") or {} - if (row["binding_id"] == saved["binding_id"] and row["source"]["source_ref"] == saved["source_ref"] - and resources.get("goal_id") in allowed): + if (resources.get("goal_id") in allowed + and row["binding_id"] == saved["binding_id"] + and row["source"]["source_ref"] == saved["source_ref"]): latest[resources["goal_id"]] = row facts = [] for goal_id, row in latest.items(): @@ -603,6 +604,8 @@ def record_delivery(self, request_ref: str, *, session_id: str | None, turn_id: path = self.root / f"{request_ref}.json" with exclusive_file_lock(path, operation="record_external_chat_delivery"): row = _read_json(path) + if row.get("request_ref") != request_ref: + raise ValueError("delivery request journal unavailable") if row.get("commission_adoption_pending"): raise ValueError("commission adoption is pending; notification cannot settle the effect") if (row.get("session_id"), row.get("turn_id")) != (session_id, turn_id): diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index 8448c789a2..c32f13f3b1 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -3,7 +3,7 @@ import subprocess from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, urlparse from .control_plane.runtime.runtime_projection_route import ( @@ -330,6 +330,11 @@ def session_matches( class LarkChatRequestMixin: + if TYPE_CHECKING: + # Host-provided response fields used by this mixin; no runtime override. + def _send_error(self, message: str, *, status: int = 400, + gate: dict[str, str] | None = None, error_code: str | None = None) -> None: ... + """Loopback HTTP handlers for Goal repository and Lark topic connections.""" server: Any @@ -437,14 +442,34 @@ def _lark_apps(self) -> None: cli_bin = self._require_lark_cli() if cli_bin is None: return + query = parse_qs(urlparse(self.path).query, keep_blank_values=True) + try: + if set(query) - {"verify", "app_ref"} or any(len(values) != 1 for values in query.values()): + raise ValueError("Invalid Lark App query") + verification = query.get("verify", ["true"])[0] + if verification not in {"true", "false"}: + raise ValueError("Lark App verify must be true or false") + app_ref = query.get("app_ref", [None])[0] + runner = self._lark_runner() + # Settings observations have a bounded probe. Mutation owners keep + # their existing fresh provider/identity checks and timeout policy. + def observation_runner(args: list[str], cwd: Path | None, timeout: float | None) -> dict[str, Any]: + return runner(args, cwd, min(timeout or 5, 5)) + apps = list_lark_apps( + runner=observation_runner if query else runner, + cli_bin=cli_bin, verify=verification == "true", app_ref=app_ref, + ) + except ValueError as exc: + self._send_error(str(exc), status=400, error_code="invalid_lark_app") + return + except RuntimeError as exc: + self._send_error(str(exc), status=502, error_code="lark_configuration_unavailable") + return self._send_json( { "ok": True, "schema_version": "loopx_lark_apps_v0", - "apps": list_lark_apps( - runner=self._lark_runner(), - cli_bin=cli_bin, - ), + "apps": apps, } ) diff --git a/loopx/control_plane/agents/agent_scope.py b/loopx/control_plane/agents/agent_scope.py index 1ab2478165..1dbf5fba29 100644 --- a/loopx/control_plane/agents/agent_scope.py +++ b/loopx/control_plane/agents/agent_scope.py @@ -1359,6 +1359,11 @@ def _agent_scope_no_candidate_frontier( if work_lane_contract_is_due_monitor_attempt(work_lane_contract): return None if isinstance(agent_lane_next_action, dict): + # Eligibility and real delivery gates have already been qualified by + # the typed action-selection owner. Priority ranks the default route; + # it must not replace an explicitly selected eligible Todo. + if agent_lane_next_action.get("selected_by") == "agent_action_selection": + return None return _selected_candidate_priority_frontier( agent_id=agent_id, summary=agent_todo_summary, diff --git a/loopx/control_plane/collaboration/inbox.py b/loopx/control_plane/collaboration/inbox.py index 47485b269e..3aa08c7368 100644 --- a/loopx/control_plane/collaboration/inbox.py +++ b/loopx/control_plane/collaboration/inbox.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any -from ...file_lock import exclusive_file_lock +from ...file_lock import exclusive_cross_runtime_file_lock from ..runtime.file_paths import windows_extended_path from ..content_digest import BARE_SHA256_PATTERN, ENVELOPED_SHA256_PATTERN from ..todos.contract import TODO_ID_PATTERN @@ -104,7 +104,7 @@ def _request_lock( if scope is not None and scope.exact else legacy_path ) - with exclusive_file_lock(lock): + with exclusive_cross_runtime_file_lock(lock): yield @@ -145,234 +145,50 @@ def plan_request_publication(request, *, entry, operation=None): raise ValueError(str(exc)) from exc -def _inbox_item(row: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]: - """Present the existing typed receipt state without changing its lifecycle.""" - item = {**row, "inbox_state": state["kind"]} - item.pop("request_control", None) - if state.get("request_control_view") is not None: - view = state["request_control_view"] - item.update(original_brief=row["brief"], brief=view["brief"], request_control_view=view, - request_revision=view["revision"], request_revision_digest=view["revision_digest"]) - item["source_request"] = {"request_id": row["request_id"], "request_revision": view["revision"], - "request_digest": view["revision_digest"]} - item["recorded_decision"] = state["recorded_decision"] - if state["recorded_decision"] is not None: - item["receiver_decision_recorded"] = True - item["next_action"] = "Review this request's recorded decision and linked work before returning its actual result. A recorded adoption is not a completed task; preserve the current owner and do not reprioritize unrelated work." - if state["warnings"]: - item["warnings"] = state["warnings"] - item["next_action"] = "Receiver receipt readback is unavailable or conflicting; recover the original receipt before continuing this request. Do not repeat the decision, result or execution. Other requests can continue." - elif state["kind"] == "settled": - item["next_action"] = "Inspect the concluded request as historical source. Reading it does not reopen work, repeat its result or grant execution authority." - elif state.get("request_control_view") is not None: - item["next_action"] = state["request_control_view"]["receiver_instruction"] - return item def pending( - runtime_root: Path, - goal_id: str, - agent_id: str, - *, - cursor: str | None = None, - operation_cursor: str | None = None, - request_id: str | None = None, + runtime_root: Path, goal_id: str, agent_id: str, *, cursor: str | None = None, + operation_cursor: str | None = None, request_id: str | None = None, scope: CollaborationGoalScope | None = None, ) -> dict: - if request_id is not None: - if cursor is not None or operation_cursor is not None: - raise ValueError("an exact request read cannot use inbox cursors") - try: - row = _entry(runtime_root, goal_id, agent_id, request_id, scope=scope) - except FileNotFoundError as exc: - raise ValueError("context request not found for this Goal and Agent") from exc - if row.get("schema_version") != (EXACT_ENTRY_SCHEMA if scope is not None and scope.exact else ENTRY_SCHEMA): - raise ValueError("context inbox scope mismatch") - if scope is not None: - from .goal_instance_scope import decide_collaboration_lifecycle - - lifecycle = decide_collaboration_lifecycle(scope, operation="inbox_observe", record=row) - if lifecycle.get("kind") == "omit": - raise ValueError("context request is unavailable in this Goal lifetime") - state = _pending_receipt_states(runtime_root, [row])[0] - return {"ok": True, "request_id": request_id, "items": [_inbox_item(row, state)], - "has_more": False, "next_cursor": None, - "instruction": REQUEST_TRIAGE_INSTRUCTION + " This is an exact source read, including concluded requests; it does not reopen their work."} - cursor_scope = _hash( - ( - [ - "pending_requests_v1", - str(runtime_root.resolve()), - _target(goal_id, agent_id, scope), - ] - if scope is not None and scope.exact - else [ - "pending_requests_v1", - str(runtime_root.resolve()), - goal_id, - agent_id, - ] - ) - ) - after = "" - if cursor is not None: - if not isinstance(cursor, str) or not re.fullmatch( - r"1:[a-f0-9]{64}:[a-f0-9]{64}", cursor - ): - raise ValueError("invalid pending request cursor") - _, supplied_scope, after = cursor.split(":") - if supplied_scope != cursor_scope: - raise ValueError("pending request cursor scope mismatch") - folder = ( - _root(runtime_root) - / "entries" - / _hash(_target(goal_id, agent_id, scope)) - ) - try: - paths = sorted(folder.iterdir()) - except FileNotFoundError: - paths = [] - items, batch = [], [] - - def append_batch(): - classified = iter(_pending_receipt_states(runtime_root, batch)) - for row in batch: - if isinstance(row, Exception): - raise row - state = next(classified) - if state["kind"] == "settled": - continue - if scope is not None: - from .goal_instance_scope import decide_collaboration_lifecycle - - lifecycle = decide_collaboration_lifecycle( - scope, operation="inbox_observe", record=row, - ) - if lifecycle.get("kind") == "omit": - continue - items.append(_inbox_item(row, state)) - if len(items) == 21: - break - batch.clear() - - for path in paths: - if path.suffix != ".json" or not BARE_SHA256_PATTERN.fullmatch(path.stem): - continue # Ignore lock holder sidecars and other non-entry files. - if path.stem <= after: - continue - try: - batch.append(_pending_entry(path, goal_id, agent_id, scope)) - except (OSError, ValueError) as exc: - # Evaluate errors in scan order; batch lookahead cannot let a later - # page's damaged entry block an already complete current page. - batch.append(exc) - if len(batch) == 128: - append_batch() - if len(items) == 21: - break - if batch: - append_batch() - from .peers import returns - - peer_returns = returns(runtime_root, goal_id, agent_id, scope=scope) + from .inbox_read import read_inbox_transaction + + snapshot = read_inbox_transaction(runtime_root, { + "operation": "pending", "goal_id": goal_id, "agent_id": agent_id, + "cursor": cursor, "operation_cursor": operation_cursor, "request_id": request_id, + }, scope=scope) + snapshot.update(goal_id=goal_id, agent_id=agent_id) + return _pending_result(runtime_root, snapshot, scope, operation_cursor) + + +def _pending_result(root, snapshot, scope, operation_cursor): + result = snapshot["result"] + if "request_id" in result or snapshot.get("completed"): + return result from .operation_handoff import pending_operation_handoffs - operation_handoffs = pending_operation_handoffs( - runtime_root, - goal_id, - agent_id, - registry_path=scope.registry_path if scope is not None else None, - scope=scope, - cursor=operation_cursor, - cursor_scope=cursor_scope, + handoffs = pending_operation_handoffs( + root, snapshot["goal_id"], + snapshot["agent_id"], registry_path=scope.registry_path if scope else None, + scope=scope, cursor=operation_cursor, cursor_scope=snapshot["cursor_scope"], ) - return { - "ok": True, - **( - { - "operation_handoffs": operation_handoffs["items"], - "operation_handoff_pending_count": operation_handoffs["pending_count"], - "operation_handoff_overflow": operation_handoffs["overflow"], - "operation_handoff_next_cursor": operation_handoffs["next_cursor"], - } - if operation_handoffs["items"] or operation_cursor is not None - else {} - ), - **({"peer_returns": peer_returns} if peer_returns["items"] else {}), - "items": items[:20], - "has_more": len(items) > 20, - "next_cursor": ( - f"1:{cursor_scope}:{items[19]['request_id']}" - if len(items) > 20 - else None - ), - "instruction": ( - REQUEST_TRIAGE_INSTRUCTION - + " Follow next_cursor to read later pending requests. Restart without a cursor " - "to discover new requests before it. The end of a page sequence is not work completion." - ), - } + if handoffs["items"] or operation_cursor is not None: + result.update(operation_handoffs=handoffs["items"], + operation_handoff_pending_count=handoffs["pending_count"], + operation_handoff_overflow=handoffs["overflow"], + operation_handoff_next_cursor=handoffs["next_cursor"]) + return result -def _pending_entry(path, goal_id, agent_id, scope): - if not BARE_SHA256_PATTERN.fullmatch(path.stem): - raise ValueError("invalid context request filename") - item = _read(path) - if ( - item.get("schema_version") - != (EXACT_ENTRY_SCHEMA if scope is not None and scope.exact else ENTRY_SCHEMA) - or item.get("goal_id") != goal_id - or item.get("agent_id") != agent_id - or item.get("request_id") != path.stem - ): - raise ValueError("context inbox scope mismatch") - return item def _pending_receipt_states(runtime_root, rows): - """Observe private files, then batch the typed read model by wire bytes.""" - from ..effect_runtime import effect_runtime_result + from .inbox_read import read_inbox_transaction - root = _root(runtime_root) - observations, states, encoded_bytes = [], [], 0 - for row in rows: - if isinstance(row, Exception): - continue - request_id = row["request_id"] - observation = { - "request": {key: row[key] for key in ( - "request_id", "goal_id", "agent_id", "source_id", "goal_ref" - ) if key in row}, - "route_present": (root / "roundtrips" / (request_id + ".json")).exists(), - "decision": _observe_receipt(root / "decisions" / (request_id + ".json")), - "conclusion": _observe_receipt(root / "replies" / request_id / "conclusion.json"), - } - if "request_control" in row: - observation["request"] = row - observation["read"] = _observe_receipt(root / "reads" / (request_id + ".json")) - results = [] - for path in result_paths(root / "replies" / request_id): - try: - results.append(_result_observation(path.stem, _read(path))) - except (OSError, ValueError, TypeError): - results.append({"unavailable": True}) - observation["results"] = results - size = len(json.dumps(observation, separators=(",", ":")).encode()) - # The existing bridge serializes ASCII-escaped JSON and admits 2 MiB. - # Leave room for its envelope; legal Unicode replies must not turn - # a bounded page into another oversized history request. - if observations and encoded_bytes + size > 1_000_000: - states.extend(effect_runtime_result( - "collaboration.inbox.inspect_receipts", {"observations": observations} - )["items"]) - observations, encoded_bytes = [], 0 - observations.append(observation) - encoded_bytes += size - if observations: - states.extend(effect_runtime_result( - "collaboration.inbox.inspect_receipts", {"observations": observations} - )["items"]) - return states + return read_inbox_transaction(runtime_root, { + "operation": "receipt_states", "rows": rows, + })["items"] def controlled_request_readback(root: Path, row: dict[str, Any]) -> dict[str, Any] | None: @@ -493,63 +309,19 @@ def _entry(root: Path, goal_id: str, agent_id: str, request_id: str, *, return row -def record_read( - root: Path, - items: list[dict], - *, - scope: CollaborationGoalScope | None = None, -) -> None: - """CLI supplied these messages to the receiver; not proof of comprehension.""" - for row in items: - _entry( - root, - row["goal_id"], - row["agent_id"], - row["request_id"], - scope=scope, - ) - if scope is not None: - from .goal_instance_scope import decide_collaboration_lifecycle +def record_read(root: Path, items: list[dict], *, scope: CollaborationGoalScope | None = None) -> None: + """Record supplied source without certifying comprehension or adoption.""" + if not items: + return + from .inbox_read import read_inbox_transaction - decide_collaboration_lifecycle( - scope, - operation="read_record", - record=row, - ) - path = _root(root) / "reads" / (row["request_id"] + ".json") - with _request_lock( - root, - row["request_id"], - scope, - path.with_suffix(".lock"), - ): - stored = _entry(root, row["goal_id"], row["agent_id"], row["request_id"], scope=scope) - if "request_control" in stored: - from ..effect_runtime import effect_runtime_result - - decision, error = _receipt(root, "decisions", stored) - plan = effect_runtime_result("collaboration.request.record_read", { - "entry": stored, "actor_agent_id": row["agent_id"], "observed_at": _now(), - "revision": row.get("request_revision"), "revision_digest": row.get("request_revision_digest"), - "legacy_assessment": decision or None, "legacy_assessment_unavailable": bool(error)}) - if plan["changed"]: - entry_path = _root(root) / "entries" / _hash(_target(row["goal_id"], row["agent_id"], scope)) / (row["request_id"] + ".json") - _write(entry_path, plan["entry"]) - row["request_read_receipt"] = plan["readback"] - continue - if not path.exists(): - _write( - path, - { - k: row[k] - for k in ("request_id", "goal_id", "agent_id", "goal_ref") - if k in row - } - | { - "read_at": _now(), - "kind": "receiver_cli_read", - }, - ) + result = read_inbox_transaction(root, { + "operation": "record_read", "goal_id": items[0]["goal_id"], + "agent_id": items[0]["agent_id"], "items": items, "deliveries": [], + }, scope=scope) + for row, receipt in zip(items, result["read_receipts"], strict=True): + if receipt is not None: + row["request_read_receipt"] = receipt def _receipt(root: Path, lane: str, row: dict[str, Any]) -> tuple[dict[str, Any], str | None]: @@ -719,31 +491,11 @@ def iter_result_paths(folder: Path): yield from result_paths(request_folder) -DELIVERY_STATUSES = { - "admitted", - "queued", - "retry_pending", - "verification_required", - "delivered", - "superseded", - "explicit_unverified", -} -DELIVERY_ERRORS = { - "provider_delivery_unverified", - "provider_locator_unavailable", - "provider_verifier_unavailable", - "provider_verification_unavailable", - "provider_delivery_intent_conflict", - "provider_message_missing", - "provider_delivery_mismatch", - "return_authorization_unavailable", - "original_route_unavailable", - "initial_delivery_receipt_unavailable", - "original_route_or_return_delivery_unavailable", - "delivery_state_unreadable", - "return_transport_unavailable", - "manager_return_payload_conflict", -} +# Retained public re-exports share the native delivery contract. +_delivery_contract = json.loads(Path(__file__).with_name("return_delivery_contract.json").read_text(encoding="utf-8")) +DELIVERY_STATUSES = set(_delivery_contract["delivery_statuses"]) +DELIVERY_ERRORS = set(_delivery_contract["delivery_errors"]) + def reply_status(root, row): result = [] diff --git a/loopx/control_plane/collaboration/inbox_read.py b/loopx/control_plane/collaboration/inbox_read.py new file mode 100644 index 0000000000..0a712b58fd --- /dev/null +++ b/loopx/control_plane/collaboration/inbox_read.py @@ -0,0 +1,42 @@ +"""Transport for the native Inbox owner; callers retain their Goal lifetime fence.""" +from pathlib import Path + +from ..effect_runtime import ( + EffectRuntimeIOError, + EffectRuntimeRejected, + effect_runtime_result, +) + + +def read_inbox_transaction(root: Path, request: dict, *, scope=None) -> dict: + from .inbox import _now + + facts = { + "runtime_root": str(Path(root).resolve()), + "scope": { + "profile_id": scope.profile_id if scope is not None else None, + "caller_goal_ref": scope.caller_goal_ref if scope is not None else None, + "current_goal_ref": scope.current_goal_ref if scope is not None else None, + }, + "observed_at": _now(), + **request, + } + if request.get("operation") == "record_read": + facts["items"] = [{key: item[key] for key in ( + "goal_id", "agent_id", "goal_ref", "request_id", "request_revision", "request_revision_digest", + ) if key in item} for item in request["items"]] + # Full source pages may exceed the ordinary wire even when every record is legal. + snapshot = request["operation"] in {"pending", "turn_start", "receiver_read", "returns"} + snapshot = snapshot or len(request.get("rows", [])) > 1 + try: + result = effect_runtime_result("collaboration.inbox.read", facts, large_local_snapshot=snapshot) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc + except EffectRuntimeIOError as exc: + # The native owner reports filesystem failures as typed runtime errors. + # Callers and the CLI still classify them as IO failures; keep the + # retryable/permanent diagnostic instead of leaking a runtime traceback. + raise OSError(f"{exc.diagnostic_code}: {exc}") from exc + if not isinstance(result, dict): + raise RuntimeError("native Inbox read must return an object") + return result diff --git a/loopx/control_plane/collaboration/inbox_read.ts b/loopx/control_plane/collaboration/inbox_read.ts new file mode 100644 index 0000000000..fd76f52b13 --- /dev/null +++ b/loopx/control_plane/collaboration/inbox_read.ts @@ -0,0 +1,466 @@ +import deliveryContract from "./return_delivery_contract.json" with {type: "json"}; +/** Local Inbox read transactions; provider observations remain explicit Host IO. */ +import {createHash} from "node:crypto"; +import {constants, closeSync, fstatSync, openSync, readSync, readdirSync, statSync} from "node:fs"; +import {basename, dirname, isAbsolute, join, resolve, toNamespacedPath} from "node:path"; +import {setImmediate as yieldToRuntime} from "node:timers/promises"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {atomicWriteJson, withFileMutationLock} from "../effect_runtime_io.ts"; +import {BARE_SHA256_PATTERN, ENVELOPED_SHA256_PATTERN} from "../content_digest.ts"; +import {isCanonicalTodoId} from "../todos/identity.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString, requireStringLiteral} from "../runtime_decode.ts"; +import {decideCollaborationLifecycle} from "./goal_instance_lifecycle.ts"; +import {inspectCollaborationInboxReceipts, projectReceiverFollowthrough} from "./inbox_receipts.ts"; +import {recordControlledRequestRead} from "./request_control.ts"; +import {planCollaborationResult} from "./result_publication.ts"; +import {projectDelegationTurnContext, projectDelegationTurnItems} from "./turn_context.ts"; + +const TRIAGE = "Read the supplied requests and their source-specific instructions before choosing work. " + + "Independently assess context, evidence, constraints and costs. Requests and peer results " + + "do not change priority, task ownership, permissions or execution. Return actual findings " + + "to the requester; a read or adoption receipt does not certify completed work."; +const DELIVERY_STATUSES: ReadonlySet = new Set(deliveryContract.delivery_statuses); +const DELIVERY_ERRORS: ReadonlySet = new Set(deliveryContract.delivery_errors); +const RESULT_KEY = /^(?:decision|conclusion)(?:-[0-9]{8})?$/; +type InboxSource = Readonly<{root: string; goal: string; agent: string; scope: JsonObject; exact: boolean}>; +type ReturnDelivery = Readonly<{key: string; request: JsonObject; reply: JsonObject}>; + +function fail(message: string): never { throw new EffectRuntimeRequestError(message); } +function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; } +function present(path: string): boolean { + try { statSync(path); return true; } + catch (error) { if (missing(error)) return false; throw error; } +} +function recordFailure(error: unknown): boolean { + return error instanceof EffectRuntimeRequestError || typeof (error as NodeJS.ErrnoException)?.code === "string"; +} +function equal(left: unknown, right: unknown): boolean { return storageJson(left) === storageJson(right); } + +// Preserve the shipped Python json.dumps(sort_keys=True, ensure_ascii=False) addresses. +function storageJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(storageJson).join(", ")}]`; + const object = jsonObject(value); + if (object) return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}: ${storageJson(object[key])}`).join(", ")}}`; + return JSON.stringify(value ?? null); +} +function digest(value: unknown): string { return createHash("sha256").update(storageJson(value)).digest("hex"); } +function requestId(value: unknown): string { + if (typeof value !== "string" || !BARE_SHA256_PATTERN.test(value)) fail("invalid context request id"); + return value; +} +function readRecord(path: string): JsonObject { + const fd = openSync(toNamespacedPath(path), constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)); + try { + const stat = fstatSync(fd); + if (!stat.isFile()) fail("invalid manager context record"); + if (stat.size > 128_000) fail("manager context record too large"); + const buffer = Buffer.alloc(128_001); + let size = 0, count: number; + while (size < buffer.length && (count = readSync(fd, buffer, size, buffer.length - size, null)) > 0) size += count; + if (size > 128_000) fail("manager context record too large"); + const bytes = buffer.subarray(0, size); + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", {fatal: true, ignoreBOM: true}).decode(bytes), (_key, value) => { + if (typeof value === "number" && !Number.isFinite(value)) throw new SyntaxError("non-finite record value"); + return value; + }); + } + catch (error) { + if (!(error instanceof SyntaxError || error instanceof TypeError)) throw error; + fail("invalid manager context record"); + } + return requireJsonObject(parsed, "manager context record"); + } finally { closeSync(fd); } +} +function names(folder: string): string[] { + try { return readdirSync(toNamespacedPath(folder)).sort(); } + catch (error) { if (missing(error)) return []; throw error; } +} +function target(source: InboxSource, agent = source.agent): JsonObject { + return source.exact ? {goal_ref: source.scope.caller_goal_ref, agent_id: agent} + : {goal_id: source.goal, agent_id: agent}; +} +function identity(source: InboxSource, agent = source.agent): JsonObject { + return {goal_id: source.goal, agent_id: agent, + ...(source.exact ? {goal_ref: source.scope.caller_goal_ref} : {})}; +} +function entryPath(source: InboxSource, id: string, agent = source.agent): string { + return join(source.root, "entries", digest(target(source, agent)), requestId(id) + ".json"); +} +function sourceRecord(row: JsonObject): JsonObject { + if (Object.hasOwn(row, "message") && typeof row.message !== "string" + || row.brief != null && !jsonObject(row.brief)) fail("invalid Inbox source content"); + return row; +} +function entry(source: InboxSource, id: string, agent = source.agent): JsonObject { + const row = readRecord(entryPath(source, id, agent)); + if (Object.entries({...identity(source, agent), request_id: id}).some(([key, value]) => !equal(row[key], value))) { + fail("context receipt scope mismatch"); + } + return sourceRecord(row); +} +function pendingEntry(source: InboxSource, path: string): JsonObject { + const id = basename(path, ".json"), row = readRecord(path); + if (row.schema_version !== (source.exact ? "loopx_manager_context_entry_v2" : "loopx_manager_context_entry_v1") + || row.goal_id !== source.goal || row.agent_id !== source.agent || row.request_id !== id) { + fail("context inbox scope mismatch"); + } + return sourceRecord(row); +} +function lifecycle(source: InboxSource, operation: string, row: JsonObject, route: JsonObject = {}): boolean { + const decision = decideCollaborationLifecycle({...source.scope, operation, + record_goal_ref: row.goal_ref ?? null, route_goal_ref: route.goal_ref ?? null, initial_delivery: null}); + if (decision.kind === "reject") fail(`collaboration lifecycle rejected: ${decision.code}`); + return decision.kind !== "omit"; +} +function observe(path: string): JsonObject { + try { return {state: "read", value: readRecord(path)}; } + catch (error) { + if (missing(error)) return {state: "absent"}; + if (!recordFailure(error)) throw error; + return {state: "unavailable"}; + } +} +function resultKeys(folder: string): string[] { + return names(folder).filter(name => name.endsWith(".json") && RESULT_KEY.test(name.slice(0, -5))) + .map(name => name.slice(0, -5)).sort((left, right) => { + const rank = (key: string) => key === "decision" ? 0 : key === "conclusion" ? 1 : 2; + return rank(left) - rank(right) || (left < right ? -1 : left > right ? 1 : 0); + }); +} +function resultObservation(key: string, reply: JsonObject): JsonObject { + const text = reply.text; + if (typeof text !== "string" || !text.trim()) fail("bounded reply text is required"); + // UTF-8 hashing must refuse unpaired surrogates rather than hash replacement bytes. + if (/[\uD800-\uDFFF]/u.test(text)) fail("invalid reply text encoding"); + return {key, value: Object.fromEntries(["request_id", "goal_id", "agent_id", "source_id", "goal_ref", "phase", + "decision", "result_key", "previous_result_key", "update_id", "attachments", "request_revision", + "request_revision_digest"].filter(field => Object.hasOwn(reply, field)).map(field => [field, reply[field]])), + text_chars: [...text].length, text_sha256: createHash("sha256").update(text).digest("hex")}; +} +function receiptState(root: string, row: JsonObject): JsonObject { + const id = requestId(row.request_id), folder = join(root, "replies", id); + const observation: JsonObject = {request: Object.fromEntries(["request_id", "goal_id", "agent_id", "source_id", "goal_ref"] + .filter(key => Object.hasOwn(row, key)).map(key => [key, row[key]])), + route_present: present(join(root, "roundtrips", id + ".json")), + decision: observe(join(root, "decisions", id + ".json")), conclusion: observe(join(folder, "conclusion.json"))}; + if (Object.hasOwn(row, "request_control")) { + observation.request = row; + observation.read = observe(join(root, "reads", id + ".json")); + observation.results = resultKeys(folder).map(key => { + try { return resultObservation(key, readRecord(join(folder, key + ".json"))); } + catch (error) { if (!recordFailure(error)) throw error; return {unavailable: true}; } + }); + } + return (inspectCollaborationInboxReceipts({observations: [observation]}).items as JsonObject[])[0]; +} +function inboxItem(row: JsonObject, state: JsonObject): JsonObject { + const item: JsonObject = {...row, inbox_state: state.kind}; + delete item.request_control; + const view = jsonObject(state.request_control_view); + if (view) Object.assign(item, {original_brief: row.brief, brief: view.brief, request_control_view: view, + request_revision: view.revision, request_revision_digest: view.revision_digest, + source_request: {request_id: row.request_id, request_revision: view.revision, request_digest: view.revision_digest}}); + item.recorded_decision = state.recorded_decision; + if (state.recorded_decision !== null) Object.assign(item, {receiver_decision_recorded: true, + next_action: "Review this request's recorded decision and linked work before returning its actual result. A recorded adoption is not a completed task; preserve the current owner and do not reprioritize unrelated work."}); + if ((state.warnings as unknown[]).length) Object.assign(item, {warnings: state.warnings, + next_action: "Receiver receipt readback is unavailable or conflicting; recover the original receipt before continuing this request. Do not repeat the decision, result or execution. Other requests can continue."}); + else if (state.kind === "settled") item.next_action = "Inspect the concluded request as historical source. Reading it does not reopen work, repeat its result or grant execution authority."; + else if (view) item.next_action = view.receiver_instruction; + return item; +} +async function pendingPage(source: InboxSource, params: JsonObject): Promise { + if (params.request_id != null) { + if (params.cursor != null || params.operation_cursor != null) fail("an exact request read cannot use inbox cursors"); + let row: JsonObject; + try { row = entry(source, requestId(params.request_id)); } + catch (error) { if (missing(error)) fail("context request not found for this Goal and Agent"); throw error; } + if (row.schema_version !== (source.exact ? "loopx_manager_context_entry_v2" : "loopx_manager_context_entry_v1")) fail("context inbox scope mismatch"); + if (!lifecycle(source, "inbox_observe", row)) fail("context request is unavailable in this Goal lifetime"); + return {ok: true, request_id: params.request_id, items: [inboxItem(row, receiptState(source.root, row))], + has_more: false, next_cursor: null, instruction: TRIAGE + " This is an exact source read, including concluded requests; it does not reopen their work."}; + } + const scope = digest(source.exact ? ["pending_requests_v1", params.runtime_root, target(source)] + : ["pending_requests_v1", params.runtime_root, source.goal, source.agent]); + let after = ""; + if (params.cursor != null) { + if (typeof params.cursor !== "string" || !/^1:[a-f0-9]{64}:[a-f0-9]{64}$/.test(params.cursor)) fail("invalid pending request cursor"); + const [, suppliedScope, suppliedAfter] = params.cursor.split(":"); + if (suppliedScope !== scope) fail("pending request cursor scope mismatch"); + after = suppliedAfter; + } + const folder = join(source.root, "entries", digest(target(source))), items: JsonObject[] = []; + for (const [index, name] of names(folder).entries()) { + if (index > 0 && index % 128 === 0) await yieldToRuntime(); + if (!name.endsWith(".json") || !BARE_SHA256_PATTERN.test(name.slice(0, -5)) || name.slice(0, -5) <= after) continue; + const row = pendingEntry(source, join(folder, name)), state = receiptState(source.root, row); + if (state.kind === "settled" || !lifecycle(source, "inbox_observe", row)) continue; + items.push(inboxItem(row, state)); + if (items.length === 21) break; + } + return {ok: true, items: items.slice(0, 20), has_more: items.length > 20, + next_cursor: items.length > 20 ? `1:${scope}:${items[19].request_id}` : null, _cursor_scope: scope, + instruction: TRIAGE + " Follow next_cursor to read later pending requests. Restart without a cursor " + + "to discover new requests before it. The end of a page sequence is not work completion."}; +} +function inspectResult(root: string, row: JsonObject, key: string, reply: JsonObject): JsonObject | null { + const folder = join(root, "replies", requestId(row.request_id)); + try { + const results = reply.update_id != null || reply.phase === "decision" ? resultKeys(folder).map(candidate => + resultObservation(candidate, readRecord(join(folder, candidate + ".json")))) : [resultObservation(key, reply)]; + const legacy = Object.hasOwn(row, "request_control") || Object.hasOwn(reply, "request_revision") + ? observe(join(root, "decisions", String(row.request_id) + ".json")) : {}; + const inspected = planCollaborationResult({request: row, phase: reply.phase, text: reply.text, + decision: reply.decision, update_id: reply.update_id, attachments: reply.attachments, + request_revision: reply.request_revision, legacy_assessment: legacy.value, results}); + return (reply.result_key ?? reply.phase) === key ? inspected : null; + } catch (error) { if (!recordFailure(error)) throw error; return null; } +} +function returnConsumed(root: string, key: string, row: JsonObject, reply: JsonObject, agent: unknown): boolean { + const path = join(root, "replies", requestId(row.request_id), key + ".consumed.json"); + if (!present(path)) return false; + const saved = readRecord(path), expected: JsonObject = {request_id: row.request_id, goal_id: row.goal_id, + agent_id: agent, ...(Object.hasOwn(reply, "result_key") ? {result_key: key} : {}), + ...(Object.hasOwn(row, "goal_ref") ? {goal_ref: row.goal_ref} : {})}; + if (Object.entries(expected).some(([field, value]) => !equal(saved[field], value))) fail("peer consumption receipt scope mismatch"); + return true; +} +async function collectReturns(source: InboxSource): Promise<{view: JsonObject; deliveries: ReturnDelivery[]}> { + const items: JsonObject[] = [], deliveries: ReturnDelivery[] = []; + const folder = join(source.root, "peer-operations", digest(target(source))); + for (const [index, name] of names(folder).entries()) { + if (index > 0 && index % 128 === 0) await yieldToRuntime(); + if (!name.endsWith(".json") || !BARE_SHA256_PATTERN.test(name.slice(0, -5))) continue; + const operation = readRecord(join(folder, name)); + if (operation.goal_id !== source.goal || operation.source_agent_id !== source.agent + || source.exact && !equal(operation.goal_ref, source.scope.caller_goal_ref)) fail("peer return scope mismatch"); + let row: JsonObject; + try { row = entry(source, requestId(operation.request_id), requireNonEmptyString(operation.agent_id, "peer Agent")); } + catch (error) { if (missing(error)) continue; throw error; } + if (row.source_agent_id !== source.agent || row.source_id !== operation.source_id) fail("peer return scope mismatch"); + const replies = join(source.root, "replies", String(row.request_id)); + for (const [resultIndex, key] of resultKeys(replies).entries()) { + if (resultIndex > 0 && resultIndex % 128 === 0) await yieldToRuntime(); + if (key === "decision") continue; + const reply = readRecord(join(replies, key + ".json")), inspected = inspectResult(source.root, row, key, reply); + if (["request_id", "goal_id", "agent_id", "source_id", "goal_ref"].some(field => Object.hasOwn(row, field) && !equal(reply[field], row[field])) + || reply.phase !== "conclusion" || !inspected) fail("peer reply identity conflict"); + const route = readRecord(join(source.root, "roundtrips", String(row.request_id) + ".json")); + if (!lifecycle(source, "peer_return_observe", row, route) + || returnConsumed(source.root, key, row, reply, source.agent)) continue; + items.push({request_id: row.request_id, ...(key !== "conclusion" ? {result_key: key} : {}), + agent_id: row.agent_id, parent_request_id: row.parent_request_id ?? null, + brief: inspected.request_brief ?? row.brief, + ...(Object.hasOwn(inspected, "request_revision") ? {request_revision: inspected.request_revision, + current_request_revision: inspected.current_request_revision} : {}), + text: reply.text, decision: reply.decision, created_at: reply.created_at}); + if (items.length > 20) break; + deliveries.push({key, request: Object.fromEntries(["request_id", "goal_id", "agent_id", "goal_ref"] + .filter(field => Object.hasOwn(row, field)).map(field => [field, row[field]])), + reply: Object.hasOwn(reply, "result_key") ? {result_key: reply.result_key} : {}}); + } + if (items.length > 20) break; + } + return {view: {items: items.slice(0, 20), has_more: items.length > 20}, deliveries}; +} +function replyStatuses(root: string, row: JsonObject): JsonObject[] { + const folder = join(root, "replies", requestId(row.request_id)); + return resultKeys(folder).map(key => { + const phase = key === "decision" || key.startsWith("decision-") ? "decision" : "conclusion"; + const identity = {phase, ...(key !== phase ? {result_key: key} : {})}; + let reply: JsonObject, state: JsonObject; + try { + reply = readRecord(join(folder, key + ".json")); + const path = join(folder, key + ".delivery.json"); + state = present(path) ? readRecord(path) : {}; + } catch (error) { + if (!recordFailure(error)) throw error; + return {...identity, status: "explicit_unverified", created_at: null, delivered_at: null, error: "delivery_state_unreadable"}; + } + let status = Object.hasOwn(state, "status") ? state.status : "queued", error = state.error ?? null; + if (jsonObject(status) || Array.isArray(status) || jsonObject(error) || Array.isArray(error)) { + fail("invalid return delivery state"); + } + if (typeof status !== "string" || !DELIVERY_STATUSES.has(status) + || error !== null && (typeof error !== "string" || !DELIVERY_ERRORS.has(error))) { + status = "explicit_unverified"; error = "delivery_state_unreadable"; + } + return {...identity, status, created_at: reply.created_at ?? null, + delivered_at: typeof state.delivered_at === "string" && [...state.delivered_at].length <= 80 ? state.delivered_at : null, + error, ...(state.verification === "reconciled_after_restart" ? {verification: state.verification} : {})}; + }); +} +function agendaResults(root: string, row: JsonObject): JsonObject[] { + const folder = join(root, "replies", requestId(row.request_id)); + const statuses = new Map(replyStatuses(root, row).map(status => [status.result_key ?? status.phase, status.status])); + return resultKeys(folder).flatMap(key => { + const reply = readRecord(join(folder, key + ".json")); + if (reply.phase !== "conclusion") return []; + if (!inspectResult(root, row, key, reply)) fail("delegation result identity unavailable"); + return [{result_key: key, request_revision: reply.request_revision ?? 1, status: statuses.get(key), + consumed: row.source_kind === "peer" && returnConsumed(root, key, row, reply, row.source_agent_id)}]; + }); +} +function linksReceipt(root: string, row: JsonObject): {value: JsonObject; unavailable: boolean} { + const observation = observe(join(root, "links", requestId(row.request_id) + ".json")); + if (observation.state === "absent") return {value: {}, unavailable: false}; + const value = jsonObject(observation.value); + const validRefs = (key: string, valid: (ref: string) => boolean) => Array.isArray(value?.[key]) && (value![key] as unknown[]).length <= 16 + && (value![key] as unknown[]).every(ref => typeof ref === "string" && valid(ref)); + const valid = value && ["goal_id", "agent_id", "request_id", "goal_ref"].every(field => !Object.hasOwn(row, field) || equal(value[field], row[field])) + && validRefs("todo_ids", isCanonicalTodoId) && validRefs("evidence_ids", ref => ENVELOPED_SHA256_PATTERN.test(ref)); + return {value: valid ? value : {}, unavailable: !valid}; +} +async function turnContext(source: InboxSource, params: JsonObject): Promise { + let complete = true, unavailable = 0; + const batches: JsonObject[] = [], observations: JsonObject[] = []; + const flush = () => { if (observations.length) batches.push(projectDelegationTurnItems({observations: observations.splice(0)})); }; + for (const direction of ["incoming", "outgoing"] as const) { + const folder = join(source.root, direction === "incoming" ? "entries" : "peer-operations", digest(target(source))); + const paths = names(folder).filter(name => name.endsWith(".json") && BARE_SHA256_PATTERN.test(name.slice(0, -5))); + complete &&= paths.length <= 2000; + for (const [index, name] of paths.slice(0, 2000).entries()) { + if (index > 0 && index % 128 === 0) await yieldToRuntime(); + let row: JsonObject; + try { + if (direction === "incoming") row = pendingEntry(source, join(folder, name)); + else { + const operation = readRecord(join(folder, name)); + if (operation.goal_id !== source.goal || operation.source_agent_id !== source.agent + || source.exact && !equal(operation.goal_ref, source.scope.caller_goal_ref)) fail("delegation operation scope mismatch"); + row = entry(source, requestId(operation.request_id), requireNonEmptyString(operation.agent_id, "peer Agent")); + if (row.source_agent_id !== source.agent || row.source_id !== operation.source_id) fail("delegation request scope mismatch"); + } + if (!lifecycle(source, "inbox_observe", row)) continue; + } catch (error) { if (!recordFailure(error)) throw error; unavailable++; continue; } + const state = receiptState(source.root, row), links = linksReceipt(source.root, row); + let results: JsonObject[] = [], evidenceUnavailable = links.unavailable; + try { results = agendaResults(source.root, row); } + catch (error) { if (!recordFailure(error)) throw error; evidenceUnavailable = true; } + const view = jsonObject(state.request_control_view); + const receipt: JsonObject = {kind: state.kind, recorded_decision: state.recorded_decision}; + if (view) receipt.request_control_view = {control_state: view.control_state, active_reservation: view.active_reservation, + revision: view.revision, revision_digest: view.revision_digest, brief: {purpose: (view.brief as JsonObject).purpose}}; + observations.push({direction, request: {...Object.fromEntries(["request_id", "agent_id", "source_kind", "source_agent_id", "goal_ref"] + .filter(key => Object.hasOwn(row, key)).map(key => [key, row[key]])), + purpose: (jsonObject(row.brief)?.purpose || [...(row.message as string ?? "")].slice(0, 2000).join(""))}, + receipt, todo_ids: links.value.todo_ids ?? [], evidence_unavailable: evidenceUnavailable, results}); + if (observations.length === 128) flush(); + } + } + flush(); + return projectDelegationTurnContext({batches, scan_complete: complete, unavailable_count: unavailable, + observed_at: params.observed_at, read_command: params.read_command}); +} +async function recordReads(source: InboxSource, params: JsonObject): Promise { + if (!Array.isArray(params.items) || params.items.length > 20 + || !Array.isArray(params.deliveries) || params.deliveries.length > 20) fail("bounded inbox read observations are required"); + const followthrough = params.work_observations == null ? [] + : projectReceiverFollowthrough({observations: params.work_observations}).items; + const receipts: (JsonObject | null)[] = []; + for (const raw of params.items) { + const row = requireJsonObject(raw, "supplied request"), id = requestId(row.request_id); + if (row.goal_id !== source.goal || row.agent_id !== source.agent) fail("context receipt scope mismatch"); + entry(source, id); + lifecycle(source, "read_record", row); + const readPath = join(source.root, "reads", id + ".json"); + const lock = source.exact ? join(source.root, "request-locks", id) : readPath.slice(0, -5) + ".lock"; + receipts.push(await withFileMutationLock(lock, async () => { + const stored = entry(source, id); + if (Object.hasOwn(stored, "request_control")) { + const observation = observe(join(source.root, "decisions", id + ".json")); + const plan = recordControlledRequestRead({entry: stored, actor_agent_id: row.agent_id, + observed_at: params.observed_at, revision: row.request_revision, revision_digest: row.request_revision_digest, + legacy_assessment: observation.value ?? null, legacy_assessment_unavailable: observation.state === "unavailable"}); + if (plan.changed) await atomicWriteJson(entryPath(source, id), plan.entry as JsonObject); + return plan.readback as JsonObject; + } + if (!present(readPath)) await atomicWriteJson(readPath, + {...Object.fromEntries(["request_id", "goal_id", "agent_id", "goal_ref"].filter(key => Object.hasOwn(row, key)).map(key => [key, row[key]])), + read_at: params.observed_at, kind: "receiver_cli_read"}); + return null; + })); + } + for (const raw of params.deliveries) { + const delivered = requireJsonObject(raw, "supplied peer result"), key = requireNonEmptyString(delivered.key, "result key"); + if (!RESULT_KEY.test(key)) fail("invalid peer result key"); + const row = requireJsonObject(delivered.request, "peer request"), reply = requireJsonObject(delivered.reply, "peer result"); + if (row.goal_id !== source.goal || Object.hasOwn(reply, "result_key") && reply.result_key !== key) fail("peer return scope mismatch"); + const path = join(source.root, "replies", requestId(row.request_id), key + ".delivery.json"); + const lock = source.exact ? join(source.root, "request-locks", String(row.request_id)) : join(dirname(path), key + ".lock"); + await withFileMutationLock(lock, async () => { + const stored = entry(source, String(row.request_id), requireNonEmptyString(row.agent_id, "peer Agent")); + if (stored.source_agent_id !== source.agent) fail("peer return scope mismatch"); + lifecycle(source, "read_record", stored); + if (!present(path)) await atomicWriteJson(path, {status: "delivered", + ...(Object.hasOwn(reply, "result_key") ? {result_key: key} : {}), delivered_at: params.observed_at, + kind: "requester_cli_read", ...(Object.hasOwn(stored, "goal_ref") ? {goal_ref: stored.goal_ref} : {})}); + }); + } + return {read_receipts: receipts, followthrough, + ...(params.include_context === true ? {context: await turnContext(source, params)} : {})}; +} + +function receiverWork(source: InboxSource, items: JsonObject[]): {observations: JsonObject[]; needsProvider: boolean} { + let needsProvider = false; + const observations = items.map(item => { + const links = linksReceipt(source.root, item), tids = (links.value.todo_ids ?? []) as unknown[]; + // The existing Host reader also resolves the workspace/alias for an empty input list. + needsProvider ||= tids.length > 0 || Object.hasOwn(item, "brief"); + return {kind: item.inbox_state, recorded_decision: item.recorded_decision, + linked_todos: [], evidence_refs: links.value.evidence_ids ?? [], evidence_unavailable: links.unavailable}; + }); + return {observations, needsProvider}; +} + +export async function readCollaborationInbox(params: JsonObject): Promise { + const runtime = requireNonEmptyString(params.runtime_root, "runtime root"); + if (!isAbsolute(runtime)) fail("inbox runtime root must be absolute"); + const root = toNamespacedPath(join(runtime, ".local", "manager-context")); + const operation = requireStringLiteral(params.operation, ["pending", "turn_context", "turn_start", "receiver_read", + "record_read", "returns", "receipt_states"], "inbox read operation"); + if (operation === "receipt_states") { + if (!Array.isArray(params.rows) || params.rows.length > 128) fail("bounded inbox requests are required"); + return {items: params.rows.map(row => receiptState(root, requireJsonObject(row, "inbox request")))}; + } + const scope = requireJsonObject(params.scope, "Goal scope"); + if (scope.profile_id !== null && scope.profile_id !== "source_session_v1") fail("unsupported collaboration profile"); + const source: InboxSource = {root, goal: requireNonEmptyString(params.goal_id, "Goal"), + agent: requireNonEmptyString(params.agent_id, "Agent"), scope, exact: scope.profile_id === "source_session_v1"}; + const observedAt = requireNonEmptyString(params.observed_at, "Inbox observation time"); + if ([...observedAt].length > 80) fail("Inbox observation time is too long"); + if (operation === "turn_context" || operation === "turn_start" || operation === "receiver_read" || params.include_context === true) { + requireNonEmptyString(params.read_command, "Inbox read command"); + } + if (operation === "record_read") return recordReads(source, params); + if (operation === "returns") { + const offered = await collectReturns(source); + if (params.mark_read === true) await recordReads(source, {...params, items: [], deliveries: offered.deliveries}); + return offered.view; + } + if (operation === "turn_context") return turnContext(source, params); + const page = await pendingPage(source, {...params, runtime_root: resolve(runtime)}); + const cursorScope = page._cursor_scope; + delete page._cursor_scope; + const returns = params.request_id == null ? await collectReturns(source) : {view: {items: []}, deliveries: []}; + if ((returns.view.items as unknown[]).length) page.peer_returns = returns.view; + if (operation === "turn_start") return {result: page, cursor_scope: cursorScope, context: await turnContext(source, params)}; + if (operation === "receiver_read") { + const work = receiverWork(source, page.items as JsonObject[]); + if (params.defer_recording !== true && !work.needsProvider) { + for (const [index, observation] of work.observations.entries()) { + if (observation.evidence_unavailable) { + const item = (page.items as JsonObject[])[index]; + item.warnings = [...(item.warnings as unknown[] ?? []), "links_unreadable_or_conflicting"]; + } + } + const final = await recordReads(source, {...params, items: page.items, deliveries: returns.deliveries, + work_observations: work.observations, include_context: params.request_id == null}); + return {result: page, cursor_scope: cursorScope, completed: true, ...final}; + } + } + return {result: page, cursor_scope: cursorScope, ...(operation === "receiver_read" ? {deliveries: returns.deliveries} : {})}; +} diff --git a/loopx/control_plane/collaboration/links.py b/loopx/control_plane/collaboration/links.py index 61e4346c23..d6b26e56b1 100644 --- a/loopx/control_plane/collaboration/links.py +++ b/loopx/control_plane/collaboration/links.py @@ -9,7 +9,6 @@ from ...control_plane.todos.list_readback import list_goal_todos from ..runtime.public_safety import public_safe_compact_text from ..todos.contract import TODO_ID_PATTERN -from ..effect_runtime import effect_runtime_result from ..content_digest import ENVELOPED_SHA256_PATTERN from .inbox import _entry, _now, _receipt, _request_lock, _root, _write from .goal_instance_scope import collaboration_goal_scope, decide_collaboration_lifecycle @@ -72,31 +71,19 @@ def read_linked_work(root, registry_path, item, todos_cache, *, include_core_det } -def receiver_followthrough(root, registry_path, items): - """Compose one bounded read model for CLI and scoped MCP receivers. - - Does not select work, create a Todo, infer links from prose or write a - decision on behalf of another Agent. The typed owner supplies advice. - """ +def receiver_work_observations(root, registry_path, items): + """Read the actual linked Core provider once per Goal instance.""" todos_cache, observations = {}, [] for item in items: work = read_linked_work(root, registry_path, item, todos_cache, include_core_details=True) observations.append({ - "kind": item["inbox_state"], - "recorded_decision": item["recorded_decision"], - "linked_todos": work["linked_todos"], - "evidence_refs": work["evidence_refs"], + "kind": item["inbox_state"], "recorded_decision": item["recorded_decision"], + "linked_todos": work["linked_todos"], "evidence_refs": work["evidence_refs"], "evidence_unavailable": work["evidence_unavailable"], }) if work["warnings"]: item.setdefault("warnings", []).extend(work["warnings"]) - if items: - views = effect_runtime_result( - "collaboration.inbox.receiver_followthrough", {"observations": observations} - )["items"] - for item, view in zip(items, views, strict=True): - item["receiver_followthrough"] = view - return items + return observations def link( diff --git a/loopx/control_plane/collaboration/peers.py b/loopx/control_plane/collaboration/peers.py index e891d5784d..14e3ce2742 100644 --- a/loopx/control_plane/collaboration/peers.py +++ b/loopx/control_plane/collaboration/peers.py @@ -234,150 +234,18 @@ def request( } -def _return_consumed(path, row, reply, *, agent_id): - """One readback identity for peer consumption and the per-Turn agenda.""" - consumed = path.with_name(path.stem + ".consumed.json") - if not consumed.exists(): - return False - value = _read(consumed) - expected = {"request_id": row["request_id"], "goal_id": row["goal_id"], - "agent_id": agent_id, - **({"result_key": path.stem} if "result_key" in reply else {}), - **({"goal_ref": row["goal_ref"]} if "goal_ref" in row else {})} - if any(value.get(key) != item for key, item in expected.items()): - raise ValueError("peer consumption receipt scope mismatch") - return True - - -def _collect_returns(root, goal_id, agent_id, *, scope=None): - items = [] - deliveries = [] - folder = ( - _root(root) - / "peer-operations" - / _hash(_target(goal_id, agent_id, scope)) - ) - for operation_path in sorted(folder.glob("*.json")): - if not BARE_SHA256_PATTERN.fullmatch(operation_path.stem): - continue # Lock holder sidecars are not peer operation records. - operation = _read(operation_path) - if ( - operation.get("goal_id") != goal_id - or operation.get("source_agent_id") != agent_id - or ( - scope is not None - and scope.exact - and operation.get("goal_ref") != scope.caller_goal_ref - ) - ): - raise ValueError("peer return scope mismatch") - try: - row = _entry( - root, - goal_id, - operation["agent_id"], - operation["request_id"], - scope=scope, - ) - except FileNotFoundError: - continue # A reserved send without an entry is repaired by its exact retry. - if row.get("source_agent_id") != agent_id or row.get( - "source_id" - ) != operation.get("source_id"): - raise ValueError("peer return scope mismatch") - from .inbox import result_paths, inspect_result_identity - - for path in result_paths(_root(root) / "replies" / row["request_id"]): - if path.stem == "decision": - continue - reply = _read(path) - inspected = inspect_result_identity(reply, row, path) - if ( - any( - reply.get(k) != row.get(k) - for k in ( - "request_id", - "goal_id", - "agent_id", - "source_id", - "goal_ref", - ) - if k in row - ) - or reply.get("phase") != "conclusion" - or inspected is None - ): - raise ValueError("peer reply identity conflict") - route = _read(_root(root) / "roundtrips" / (row["request_id"] + ".json")) - if scope is not None: - lifecycle = decide_collaboration_lifecycle( - scope, - operation="peer_return_observe", - record=row, - route=route, - ) - if lifecycle.get("kind") == "omit": - continue - if _return_consumed(path, row, reply, agent_id=agent_id): - continue - items.append( - { - "request_id": row["request_id"], - **({"result_key": path.stem} if path.stem != "conclusion" else {}), - "agent_id": row["agent_id"], - "parent_request_id": row.get("parent_request_id"), - "brief": inspected.get("request_brief", row["brief"]), - **({key: inspected[key] for key in ("request_revision", "current_request_revision")} if "request_revision" in inspected else {}), - "text": reply["text"], - "decision": reply["decision"], - "created_at": reply["created_at"], - } - ) - if len(items) > 20: - break - deliveries.append((path, row, reply)) - if len(items) > 20: - break - return {"items": items[:20], "has_more": len(items) > 20}, deliveries -def _record_return_reads(root, deliveries, *, scope=None): - for path, row, reply in deliveries: - state = path.with_name(path.stem + ".delivery.json") - with _request_lock( - root, - row["request_id"], - scope, - path.with_suffix(".lock"), - ): - if not state.exists(): - _write( - state, - { - "status": "delivered", - **({"result_key": path.stem} if "result_key" in reply else {}), - "delivered_at": _now(), - "kind": "requester_cli_read", - **( - {"goal_ref": row["goal_ref"]} - if "goal_ref" in row - else {} - ), - }, - ) + + def returns(root, goal_id, agent_id, *, mark_read=False, scope=None): - """Re-offer results until the requester explicitly acknowledges consumption.""" - result, deliveries = _collect_returns( - root, - goal_id, - agent_id, - scope=scope, - ) - if mark_read: - _record_return_reads(root, deliveries, scope=scope) - return result + from .inbox_read import read_inbox_transaction + + return read_inbox_transaction(root, { + "operation": "returns", "goal_id": goal_id, "agent_id": agent_id, "mark_read": mark_read, + }, scope=scope) def consume_return( @@ -617,77 +485,54 @@ def read_inbox( request_id=None, caller_goal_ref=None, ): - from .inbox import pending - from .inbox import record_read + from .inbox import _pending_result + from .inbox_read import read_inbox_transaction + from .turn_context_observation import delegation_read_command + from .links import receiver_work_observations + read_command = delegation_read_command(root, registry, goal_id, agent_id) with collaboration_goal_scope( - registry, - goal_id=goal_id, - agents=(agent_id,), - caller_goal_ref=caller_goal_ref, + registry, goal_id=goal_id, agents=(agent_id,), caller_goal_ref=caller_goal_ref, ) as goal_scope: - result = pending( - root, - goal_id, - agent_id, - cursor=cursor, - operation_cursor=operation_cursor, - request_id=request_id, - scope=goal_scope, - ) - if request_id is None: - peer_returns, return_deliveries = _collect_returns( - root, - goal_id, - agent_id, - scope=goal_scope, - ) - else: - peer_returns, return_deliveries = {"items": []}, [] - if peer_returns["items"]: - result["peer_returns"] = peer_returns - else: - result.pop("peer_returns", None) + snapshot = read_inbox_transaction(root, { + "operation": "receiver_read", "goal_id": goal_id, "agent_id": agent_id, + "cursor": cursor, "operation_cursor": operation_cursor, "request_id": request_id, + "read_command": read_command, + "defer_recording": operation_cursor is not None or (root / "chat" / "actions" / "actions.json").is_file(), + }, scope=goal_scope) + snapshot.update(goal_id=goal_id, agent_id=agent_id) + result = _pending_result(root, snapshot, goal_scope, operation_cursor) observed_goal = dict(goal_scope.goal) - observed_goal_ref = ( - dict(goal_scope.caller_goal_ref or {}) if goal_scope.exact else None - ) - - from .links import receiver_followthrough - - receiver_followthrough(root, registry, result["items"]) - # Input hashing can touch arbitrary workspace files and does not participate - # in Goal lifetime admission. - for item in result["items"]: - if item.get("brief"): - item["input_readiness"] = _input_readiness_for_goal( - registry, - goal_id, - observed_goal, - item["brief"], - workspace=workspace, - ) - if result["items"] or return_deliveries: + observed_goal_ref = dict(goal_scope.caller_goal_ref or {}) if goal_scope.exact else None + + if not snapshot.get("completed"): + work = receiver_work_observations(root, registry, result["items"]) + # Input hashing remains outside Goal lifetime admission. + for item in result["items"]: + if item.get("brief"): + item["input_readiness"] = _input_readiness_for_goal( + registry, goal_id, observed_goal, item["brief"], workspace=workspace, + ) with collaboration_goal_scope( - registry, - goal_id=goal_id, - agents=(agent_id,), - caller_goal_ref=observed_goal_ref, + registry, goal_id=goal_id, agents=(agent_id,), caller_goal_ref=observed_goal_ref, ) as goal_scope: - if result["items"]: - record_read(root, result["items"], scope=goal_scope) - else: - decide_collaboration_lifecycle( - goal_scope, - operation="read_record", - record=return_deliveries[0][1], - ) - _record_return_reads(root, return_deliveries, scope=goal_scope) - from .turn_context_observation import read_delegation_turn_context - + final = read_inbox_transaction(root, { + "operation": "record_read", "goal_id": goal_id, "agent_id": agent_id, + "items": result["items"], + "deliveries": snapshot["deliveries"], "work_observations": work, + "include_context": request_id is None, "read_command": read_command, + }, scope=goal_scope) + else: + final = snapshot + for item in result["items"]: + if item.get("brief"): + item["input_readiness"] = [] + for item, receipt, advice in zip(result["items"], final["read_receipts"], final["followthrough"], strict=True): + if receipt is not None: + item["request_read_receipt"] = receipt + item["receiver_followthrough"] = advice if request_id is None: - result["unfinished_delegations"] = read_delegation_turn_context( - root, registry, goal_id, agent_id)["unfinished_delegations"] + result["unfinished_delegations"] = final["context"]["unfinished_delegations"] result["followthrough"] = ( "Use each request's receiver_followthrough to reconcile it with actual Core work. " "Record an explicit assessment even when continuing other work; a read is not a decision. " diff --git a/loopx/control_plane/collaboration/return_delivery_contract.json b/loopx/control_plane/collaboration/return_delivery_contract.json new file mode 100644 index 0000000000..db0839aeed --- /dev/null +++ b/loopx/control_plane/collaboration/return_delivery_contract.json @@ -0,0 +1,27 @@ +{ + "delivery_statuses": [ + "admitted", + "delivered", + "explicit_unverified", + "queued", + "retry_pending", + "superseded", + "verification_required" + ], + "delivery_errors": [ + "delivery_state_unreadable", + "initial_delivery_receipt_unavailable", + "manager_return_payload_conflict", + "original_route_or_return_delivery_unavailable", + "original_route_unavailable", + "provider_delivery_intent_conflict", + "provider_delivery_mismatch", + "provider_delivery_unverified", + "provider_locator_unavailable", + "provider_message_missing", + "provider_verification_unavailable", + "provider_verifier_unavailable", + "return_authorization_unavailable", + "return_transport_unavailable" + ] +} diff --git a/loopx/control_plane/collaboration/turn_context_observation.py b/loopx/control_plane/collaboration/turn_context_observation.py index 37873412b1..2aec38e178 100644 --- a/loopx/control_plane/collaboration/turn_context_observation.py +++ b/loopx/control_plane/collaboration/turn_context_observation.py @@ -1,114 +1,20 @@ -"""Read-only filesystem adapter for the TS-owned unfinished delegation agenda.""" -from __future__ import annotations - -import json +"""Host command formatting and Goal admission for native Inbox observations.""" import shlex from pathlib import Path -from ..content_digest import BARE_SHA256_PATTERN -from ..effect_runtime import effect_runtime_result -from .goal_instance_scope import collaboration_goal_scope, decide_collaboration_lifecycle -from .inbox import ( - _entry, _hash, _now, _pending_entry, _pending_receipt_states, _read, - _receipt, _root, _target, inspect_result_identity, result_paths, reply_status, -) - - -def _paths(folder): - # Same exact-Agent folders as inbox/peer requests; never scan other Agents' inboxes. - try: - paths = folder.iterdir() - return sorted(path for path in paths if path.suffix == ".json" and BARE_SHA256_PATTERN.fullmatch(path.stem)) - except FileNotFoundError: - return [] - +from .goal_instance_scope import collaboration_goal_scope +from .inbox_read import read_inbox_transaction -def _results(root, row): - statuses = {r.get("result_key", r["phase"]): r for r in reply_status(root, row)} - results = [] - for path in result_paths(_root(root) / "replies" / row["request_id"]): - value = _read(path) - if value.get("phase") != "conclusion": - continue - if inspect_result_identity(value, row, path) is None: - raise ValueError("delegation result identity unavailable") - from .peers import _return_consumed - consumed = row.get("source_kind") == "peer" and _return_consumed( - path, row, value, agent_id=row["source_agent_id"]) - results.append({"result_key": path.stem, "request_revision": value.get("request_revision", 1), - "status": statuses[path.stem]["status"], "consumed": consumed}) - return results +def delegation_read_command(root, registry, goal_id, agent_id): + return shlex.join(["loopx", "--registry", str(registry), "--runtime-root", str(root), + "manager-inbox", "read", "--goal-id", goal_id, "--agent-id", agent_id]) def read_delegation_turn_context(root: Path, registry: Path, goal_id: str, agent_id: str, *, read_command: str | None = None) -> dict: - read_command = read_command or shlex.join(["loopx", "--registry", str(registry), "--runtime-root", - str(root), "manager-inbox", "read", "--goal-id", goal_id, "--agent-id", agent_id]) - rows, complete, unavailable = [], True, 0 with collaboration_goal_scope(registry, goal_id=goal_id, agents=(agent_id,)) as scope: - base = _root(root) - folders = (("incoming", base / "entries" / _hash(_target(goal_id, agent_id, scope))), - ("outgoing", base / "peer-operations" / _hash(_target(goal_id, agent_id, scope)))) - for direction, folder in folders: - paths = _paths(folder) - complete = complete and len(paths) <= 2000 - for path in paths[:2000]: - try: - if direction == "incoming": - row = _pending_entry(path, goal_id, agent_id, scope) - else: - operation = _read(path) - if (operation.get("goal_id") != goal_id or operation.get("source_agent_id") != agent_id - or scope.exact and operation.get("goal_ref") != scope.caller_goal_ref): - raise ValueError("delegation operation scope mismatch") - row = _entry(root, goal_id, operation["agent_id"], operation["request_id"], scope=scope) - if row.get("source_agent_id") != agent_id or row.get("source_id") != operation.get("source_id"): - raise ValueError("delegation request scope mismatch") - if decide_collaboration_lifecycle(scope, operation="inbox_observe", record=row).get("kind") == "omit": - continue - rows.append((direction, row)) - except (OSError, ValueError, KeyError, TypeError): - unavailable += 1 - batches = [] - # The existing receipt reader batches full controlled requests by RPC wire bytes. - for start in range(0, len(rows), 128): - batch = rows[start:start + 128] - states = _pending_receipt_states(root, [row for _, row in batch]) - observations = [] - for (direction, row), state in zip(batch, states, strict=True): - links, error = _receipt(root, "links", row) - results = [] - try: - results = _results(root, row) - except (OSError, ValueError, KeyError, TypeError): - error = "delegation_result_unavailable" - # Only the selected current brief and receipt facts cross this boundary; - # source messages, transcripts, result text and private rationale do not. - control = state.get("request_control_view") - state = {"kind": state["kind"], "recorded_decision": state["recorded_decision"]} - if control is not None: - state["request_control_view"] = {key: control.get(key) for key in ( - "control_state", "active_reservation", "revision", "revision_digest")} - state["request_control_view"]["brief"] = {"purpose": control["brief"]["purpose"]} - observations.append({"direction": direction, - "request": {**{key: row[key] for key in ("request_id", "agent_id", "source_kind", - "source_agent_id", "goal_ref") if key in row}, - "purpose": (row.get("brief") or {}).get("purpose") or row.get("message", "")[:2000]}, - "receipt": state, "todo_ids": links.get("todo_ids", []), - "evidence_unavailable": bool(error), "results": results}) - # Large legal Unicode briefs must not overflow the ordinary local RPC. - pending = [] - size = 0 - for observation in observations: - encoded = len(json.dumps(observation, separators=(",", ":")).encode()) - if pending and size + encoded > 1_500_000: - batches.append(effect_runtime_result("collaboration.inbox.turn_items", {"observations": pending})) - pending, size = [], 0 - pending.append(observation) - size += encoded - if pending: - batches.append(effect_runtime_result("collaboration.inbox.turn_items", {"observations": pending})) - return effect_runtime_result("collaboration.inbox.turn_context", {"batches": batches, - "scan_complete": complete, "unavailable_count": unavailable, - "observed_at": _now(), "read_command": read_command}) + return read_inbox_transaction(root, { + "operation": "turn_context", "goal_id": goal_id, "agent_id": agent_id, + "read_command": read_command or delegation_read_command(root, registry, goal_id, agent_id), + }, scope=scope) diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index 7e18939128..22e4966316 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -42,6 +42,7 @@ MAX_REQUEST_BYTES = 2 * 1024 * 1024 MAX_LOCAL_SNAPSHOT_BYTES = 64 * 1024 * 1024 LOCAL_SNAPSHOT_METHODS = frozenset({ + "collaboration.inbox.read", "collaboration.delegation.inventory_page", "benchmark.trajectory.codex-usage", "coordination.local_authority.todo_source", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 809f5ee933..71edd1cc47 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -653,14 +653,10 @@ export function createEffectRuntimeHandlers( ["collaboration.source_context.normalize", lazyHandler(() => import("./collaboration/semantic_request.ts"), ({normalizeCollaborationSourceContext}) => normalizeCollaborationSourceContext)], ["collaboration.request.plan_publication", lazyHandler(() => import("./collaboration/request_control.ts"), ({planControlledRequestPublication}) => planControlledRequestPublication)], ["collaboration.request.control", lazyHandler(() => import("./collaboration/request_control.ts"), ({planRequestControl}) => planRequestControl)], - ["collaboration.request.record_read", lazyHandler(() => import("./collaboration/request_control.ts"), ({recordControlledRequestRead}) => recordControlledRequestRead)], ["collaboration.request.assess", lazyHandler(() => import("./collaboration/request_control.ts"), ({recordControlledRequestAssessment}) => recordControlledRequestAssessment)], ["collaboration.request.amendment_readback", lazyHandler(() => import("./collaboration/request_control.ts"), ({requestAmendmentReadback}) => requestAmendmentReadback)], ["goal.amendment.request_receipt", lazyHandler(() => import("./goals/goal_amendment_authority.ts"), ({projectRequestAmendmentReceipt}) => projectRequestAmendmentReceipt)], - ["collaboration.inbox.inspect_receipts", lazyHandler(() => import("./collaboration/inbox_receipts.ts"), ({inspectCollaborationInboxReceipts}) => inspectCollaborationInboxReceipts)], - ["collaboration.inbox.turn_items", lazyHandler(() => import("./collaboration/turn_context.ts"), ({projectDelegationTurnItems}) => projectDelegationTurnItems)], - ["collaboration.inbox.turn_context", lazyHandler(() => import("./collaboration/turn_context.ts"), ({projectDelegationTurnContext}) => projectDelegationTurnContext)], - ["collaboration.inbox.receiver_followthrough", lazyHandler(() => import("./collaboration/inbox_receipts.ts"), ({projectReceiverFollowthrough}) => projectReceiverFollowthrough)], + ["collaboration.inbox.read", lazyHandler(() => import("./collaboration/inbox_read.ts"), ({readCollaborationInbox}) => readCollaborationInbox)], ["collaboration.result.plan_publication", lazyHandler(() => import("./collaboration/result_publication.ts"), ({planCollaborationResult}) => planCollaborationResult)], ["collaboration.result.attachment_refs", lazyHandler(() => import("./collaboration/result_publication.ts"), ({resultAttachmentRefs}) => resultAttachmentRefs)], ["collaboration.result.delivery_ready", lazyHandler(() => import("./collaboration/result_publication.ts"), ({collaborationResultDeliveryReady}) => collaborationResultDeliveryReady)], diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts index 57852c25d1..9e869b39df 100644 --- a/loopx/control_plane/effect_runtime_server.ts +++ b/loopx/control_plane/effect_runtime_server.ts @@ -28,6 +28,7 @@ const MAX_REQUEST_BYTES = 2 * 1024 * 1024; const MAX_INLINE_RESPONSE_BYTES = 2 * 1024 * 1024; // Explicit opt-in: ordinary effects retain the 2 MiB request/response wire. const LOCAL_SNAPSHOT_METHODS = new Set([ + "collaboration.inbox.read", "collaboration.delegation.inventory_page", "benchmark.trajectory.codex-usage", "coordination.local_authority.todo_source", diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index 3e5137d616..4ebc286601 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -29,7 +29,6 @@ _agent_scope_deferred_resume_candidates, _agent_scope_frontier_action, _agent_scope_no_candidate_frontier, - _selected_candidate_priority_frontier, _attach_agent_identity_contracts, ) from ..agents.capability_gate import missing_required_capabilities @@ -460,22 +459,6 @@ def _delivery_preemptions_for_route( preemptions.append("control_repair") if not normal_delivery_allowed: preemptions.append("delivery_not_allowed") - requested_candidate = prepared.requested_action_candidate - agent_id = normalize_todo_claimed_by( - (prepared.agent_identity or {}).get("agent_id") - ) - if ( - not preemptions - and agent_id - and isinstance(prepared.agent_todo_summary, dict) - and isinstance(requested_candidate, dict) - and _selected_candidate_priority_frontier( - agent_id=agent_id, - summary=prepared.agent_todo_summary, - selected=requested_candidate, - ) - ): - preemptions.append("ready_deferred_successor_priority_preemption") return preemptions diff --git a/loopx/control_plane/status/goal_source.py b/loopx/control_plane/status/goal_source.py index 1572863b87..d05fc540d2 100644 --- a/loopx/control_plane/status/goal_source.py +++ b/loopx/control_plane/status/goal_source.py @@ -226,11 +226,11 @@ def _invalidate(self): self._status = None self._pages = CompletedTodoPages() - def status(self, goal_id=None): + def status(self, goal_id=None, *, fresh=False): with self._lock: try: selected = self.source.check_access(goal_id) - if (self._status is None or selected != self._selected + if (fresh or self._status is None or selected != self._selected or monotonic() - self._read_at >= 2): self._status = None self._status = self.source.read_status(selected) @@ -246,8 +246,8 @@ def describe(self): "operations": {"goals": True, "status": True, "history": True, "lifecycle": False, "conversation": False, "analysis": False}} - def goals(self, goal_id=None): - status = self.status(goal_id) + def goals(self, goal_id=None, *, fresh=False): + status = self.status(goal_id, fresh=fresh) return {"ok": True, "source": self.source.describe(), "items": status["run_history"]["goals"]} diff --git a/loopx/control_plane/work_items/planning_inventory.py b/loopx/control_plane/work_items/planning_inventory.py index 0dc11c41ac..b9982f2855 100644 --- a/loopx/control_plane/work_items/planning_inventory.py +++ b/loopx/control_plane/work_items/planning_inventory.py @@ -3,7 +3,6 @@ from collections.abc import Mapping from typing import Any -from ..agents.agent_scope import _selected_candidate_priority_frontier from ..todos.contract import ( TODO_TASK_CLASS_ADVANCEMENT, TODO_TASK_CLASS_MONITOR, @@ -53,7 +52,6 @@ def quota_runnable_action_candidates( if not isinstance(agent_todo_summary, Mapping): return [] - summary = dict(agent_todo_summary) capability_candidates = ( capability_gate.get("runnable_candidates") if isinstance(capability_gate, Mapping) @@ -86,14 +84,6 @@ def quota_runnable_action_candidates( or (claimed_by is not None and claimed_by != agent_id) ): continue - if _selected_candidate_priority_frontier( - agent_id=agent_id, - summary=summary, - selected=candidate, - ): - # The ready higher-priority successor owns this turn's lifecycle - # decision; it cannot be advertised as a runnable alternative. - continue todo_id = normalize_todo_id(candidate.get("todo_id")) if not todo_id or not protocol_action_text(candidate.get("text"), limit=500) or todo_id in seen: continue diff --git a/loopx/extensions/lark/goal_topic_connections.py b/loopx/extensions/lark/goal_topic_connections.py index bef71637e2..9593343334 100644 --- a/loopx/extensions/lark/goal_topic_connections.py +++ b/loopx/extensions/lark/goal_topic_connections.py @@ -242,11 +242,14 @@ def list_lark_apps( *, runner: CommandRunner = default_subprocess_runner, cli_bin: str = DEFAULT_CLI_BIN, + verify: bool = True, + app_ref: str | None = None, ) -> list[dict[str, Any]]: + selected = _profile_ref(app_ref) if app_ref is not None else None result = call(runner, [cli_bin, "profile", "list"]) raw_profiles = _json_value(result) if result.get("returncode") != 0 or not isinstance(raw_profiles, list): - return [] + raise RuntimeError("Could not read local Lark profiles; retry the configuration read") apps: list[dict[str, Any]] = [] for raw in raw_profiles: if not isinstance(raw, Mapping): @@ -255,7 +258,11 @@ def list_lark_apps( app_ref = _profile_ref(raw.get("name")) except ValueError: continue - identity = _app_identity(app_ref=app_ref, runner=runner, cli_bin=cli_bin) + if selected is not None and selected != app_ref: + continue + identity = _app_identity(app_ref=app_ref, runner=runner, cli_bin=cli_bin) if verify else None + if identity and raw.get("appId") and identity["app_id"] != raw["appId"]: + identity = None bot_ready = bool(identity and identity.get("ready")) apps.append( { @@ -265,9 +272,11 @@ def list_lark_apps( "active": raw.get("active") is True, "ready": bool(identity and identity.get("ready")), "reply_ready": bot_ready, - "health_error_code": None if bot_ready else "lark_app_not_ready", + "health_error_code": None if bot_ready else "lark_app_not_ready" if identity else "lark_app_unverified", } ) + if selected is not None and not apps: + raise ValueError("Lark App profile is no longer configured; refresh the configuration") return apps diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index 5e2fbc5fa0..bfae758b01 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -558,13 +558,28 @@ def admit(self, profile: str, event: dict[str, Any], *, request = identity_ref(binding["provider_ref"], event["message_id"]) control_root = self._admitted_control_root({"request_ref": request, "source": source, "event": event}, binding) - selected = (None if control_root else self.bindings.resolve( - binding_id=binding["binding_id"], **source, include_observation=True)) except (KeyError, StopIteration, ValueError): return {"status": "audience_rejected"} request = identity_ref(binding["provider_ref"], event["message_id"]) path = self.root / f"{request}.json" with exclusive_file_lock(path, operation="capture_private_chat_request"): + # Retain a candidate from the locally bound owner before provider + # identity IO can fail. This private receipt grants no execution: + # recovery still verifies the App, current owner, source and Core + # grant before creating a Turn. Groups and other senders keep their + # existing pre-capture privacy boundary. + if (not path.exists() and binding.get("enabled") is True + and binding.get("audience", "owner") == "owner" + and source.get("private_human_message") is True + and source["sender_ref"] == binding["operator_ref"]): + _atomic_write_json(path, {"schema_version": "lark_private_chat_delivery_v0", + "request_ref": request, "profile": profile, "binding_id": binding["binding_id"], + "source": source, "event": event, "deliveries": {}, "status": "captured"}) + try: + selected = (None if control_root else self.bindings.resolve( + binding_id=binding["binding_id"], **source, include_observation=True)) + except (KeyError, StopIteration, ValueError): + return {"status": "audience_rejected"} if path.exists(): record = _read_json(path) # event_id can change on redelivery; canonical message content @@ -578,6 +593,11 @@ def admit(self, profile: str, event: dict[str, Any], *, "event": event, "deliveries": {}, "status": "captured"} if binding.get("audience") != "group": _atomic_write_json(path, record) + # A transport-only rejection never owned a Core request. Its + # verified notice stays terminal on provider redelivery too. + if (record["status"] == "delivered" + and "session_id" not in record and "turn_id" not in record): + return {"status": "command_rejected"} if control_root: result = self._admit_group_control(path, record, binding, control_root, defer_background_stop=defer_background_stop) @@ -1163,7 +1183,10 @@ def inbox() -> Path: config = inbox() acknowledge_lark_event_inbox(project=self.runtime_root, config_path=config, message_ids=[record["event"]["message_id"]], execute=True) - self.core.record_delivery(record["request_ref"], session_id=record.get("session_id"), turn_id=record.get("turn_id")) + if native_path.exists(): + self.core.record_delivery(record["request_ref"], session_id=record.get("session_id"), turn_id=record.get("turn_id")) + elif record["status"] != "rejected": + raise ValueError("delivery request journal unavailable") record["status"] = "delivered" _atomic_write_json(path, record) return 1 diff --git a/loopx/presentation/research_analysis_api.py b/loopx/presentation/research_analysis_api.py index cbeb94eb9f..1a62920144 100644 --- a/loopx/presentation/research_analysis_api.py +++ b/loopx/presentation/research_analysis_api.py @@ -13,6 +13,20 @@ class ResearchAnalysisRequestMixin: + def _research_analysis_query(self): + query = parse_qs(urlparse(self.path).query, keep_blank_values=True) + namespace = getattr(self.server, "analysis_source_namespace", None) + if namespace is not None and query.pop("source_namespace", []) != [namespace]: + raise ValueError("Analysis source identity changed") + return query + + def _research_analysis_verify_source_goal(self, runtime, goal_id, pack_id, pack=None): + if getattr(self.server, "analysis_source_namespace", None) is not None: + if pack is None: + pack = store.inspect_pack(runtime_root=runtime, owner_goal_id=goal_id, pack_id=pack_id) + if pack["scope"]["goal_id"] != goal_id: + raise ValueError("The analysis input belongs to a different source Goal") + def _research_analysis_scope(self, goal_id): validate_goal_id(goal_id) selected = getattr(self.server, "selected_goal_id", None) @@ -23,7 +37,7 @@ def _research_analysis_scope(self, goal_id): def _research_analysis_read(self): try: - query = parse_qs(urlparse(self.path).query, keep_blank_values=True) + query = self._research_analysis_query() citation = urlparse(self.path).path.endswith("/citation") required = {"goal_id", "pack_id", "analysis_key", "source_id", "first_line", "last_line"} if citation else {"goal_id"} allowed = required if citation else {"goal_id", "pack_id"} @@ -35,6 +49,7 @@ def _research_analysis_read(self): if runtime is None: return if citation: + self._research_analysis_verify_source_goal(runtime, goal_id, values["pack_id"]) first = int(values.pop("first_line")) last = int(values.pop("last_line")) if first < 1 or last < first: @@ -45,7 +60,11 @@ def _research_analysis_read(self): catalog = store.discover(runtime_root=runtime, owner_goal_id=goal_id) pack_id = values.get("pack_id") or catalog["default_pack_id"] pack = store.inspect_pack(runtime_root=runtime, owner_goal_id=goal_id, pack_id=pack_id) if pack_id else None + if pack_id: + self._research_analysis_verify_source_goal(runtime, goal_id, pack_id, pack) result = {"ok": True, "catalog": catalog, "pack": pack} + if hasattr(self.server, "analysis_execution_enabled"): + result["execution_enabled"] = self.server.analysis_execution_enabled self._send_json(result) except store.PackUnavailable as exc: self._send_error("The registered analysis source is unavailable.", status=409, error_code=exc.reason_code) @@ -56,6 +75,8 @@ def _research_analysis_read(self): def _research_analysis_write(self): try: + if getattr(self.server, "analysis_source_namespace", None) is not None and self._research_analysis_query(): + raise ValueError("Only the bound analysis source selector is accepted") body = self._read_json(max_bytes=4096) cancel = urlparse(self.path).path.endswith("/cancel") required = {"goal_id", "pack_id", "analysis_key"} @@ -69,6 +90,7 @@ def _research_analysis_write(self): return args = dict(runtime_root=runtime, owner_goal_id=body["goal_id"], pack_id=body["pack_id"], analysis_key=body["analysis_key"]) + self._research_analysis_verify_source_goal(runtime, body["goal_id"], body["pack_id"]) # The server's existing threaded request is the asynchronous host. # Provider key/claim, source verification and cancellation # remain with benchmark-toolkit; nothing executes on GET. diff --git a/loopx/presentation/runtime_workspace.py b/loopx/presentation/runtime_workspace.py index 28942deca9..37ff1e4196 100644 --- a/loopx/presentation/runtime_workspace.py +++ b/loopx/presentation/runtime_workspace.py @@ -19,26 +19,33 @@ from ..capabilities.explore.result_log import explore_history_rows from ..control_plane.status.goal_source import GoalSource, GoalSourceReader, SourceScopeError -from ..status_server import cors_response_headers, is_loopback_host, is_loopback_origin +from ..status_server import cors_response_headers, is_loopback_host, is_loopback_origin, parse_strict_json_object from .chat_bundle import validate_bundle +from .research_analysis_api import RESEARCH_ANALYSIS_PATH, ResearchAnalysisRequestMixin class RuntimeWorkspaceServer(ThreadingHTTPServer): daemon_threads = True - def __init__(self, source, assets_dir, *, port=0): + def __init__(self, source, assets_dir, *, port=0, analysis_runtime_root=None): super().__init__(("127.0.0.1", port), RuntimeWorkspaceHandler) self.source = source self.reader = GoalSourceReader(source, history_projector=explore_history_rows) self.assets_dir = Path(assets_dir).resolve() self.catalog = [] + # A local analysis store never becomes the remote Goal's authority. + # Physical source identity isolates even identically named Goals. + self.analysis_runtime_root = (Path(analysis_runtime_root).expanduser().resolve() + / "goal-sources" / source.namespace) if analysis_runtime_root is not None else None + self.analysis_source_namespace = source.namespace + self.analysis_execution_enabled = self.analysis_runtime_root is not None @property def status_url(self): return f"http://127.0.0.1:{self.server_port}/status.json?source_namespace={self.source.namespace}" -class RuntimeWorkspaceHandler(BaseHTTPRequestHandler): +class RuntimeWorkspaceHandler(ResearchAnalysisRequestMixin, BaseHTTPRequestHandler): def log_message(self, *_args): pass @@ -54,6 +61,9 @@ def _json(self, payload, status=200): self.wfile.write(body) def _allowed(self): + if len(self.headers.get_all("Host", [])) != 1 or len(self.headers.get_all("Origin", [])) > 1: + self._json({"ok": False, "error": "Loopback owner access required."}, 403) + return False try: host = urlparse("//" + self.headers.get("Host", "")).hostname or "" except ValueError: @@ -64,12 +74,51 @@ def _allowed(self): return True def do_POST(self): + if self._allowed(): + if (self.server.analysis_execution_enabled + and urlparse(self.path).path in {RESEARCH_ANALYSIS_PATH, RESEARCH_ANALYSIS_PATH + "/cancel"}): + self._research_analysis_write() + return + self._json({"ok": False, "error": "This source is read-only."}, 405) + + def _reject_mutation(self): if self._allowed(): self._json({"ok": False, "error": "This source is read-only."}, 405) - do_PUT = do_POST - do_PATCH = do_POST - do_DELETE = do_POST + do_PUT = _reject_mutation + do_PATCH = _reject_mutation + do_DELETE = _reject_mutation + + def _send_json(self, payload, *, status=200): + self._json(payload, status) + + def _send_error(self, message, *, status=400, error_code=None): + self._json({"ok": False, "error": message, **({"error_code": error_code} if error_code else {})}, status) + + def _read_json(self, *, max_bytes): + if len(self.headers.get_all("Content-Length", [])) != 1 or self.headers.get("Transfer-Encoding"): + raise ValueError("Invalid analysis request framing") + length = int(self.headers.get("Content-Length") or "0") + if not 0 < length <= max_bytes: + raise ValueError("Invalid analysis request size") + return parse_strict_json_object(self.rfile.read(length)) + + def _research_analysis_scope(self, goal_id): + # Check current access and actual registered membership before local + # pack reads or provider effects. No host registry entry is created. + try: + # Display reads may share their short cache. A provider operation + # must check the original source again before admitting any work. + goals = self.server.reader.goals(goal_id, fresh=self.command == "POST")["items"] + if not any(goal["id"] == goal_id and goal.get("registry_member") is True for goal in goals): + raise SourceScopeError("Analysis requires a registered source Goal") + except SourceScopeError: + self._send_error("Goal or connection is not available in this source.", status=404) + return None + except (ValueError, OSError, subprocess.SubprocessError): + self._send_error("The original Goal source is unavailable; refresh to retry.", status=503) + return None + return self.server.analysis_runtime_root def do_OPTIONS(self): if self._allowed(): @@ -97,7 +146,9 @@ def do_GET(self): return try: if parsed.path == "/api/goal-source": - self._json(self.server.reader.describe()) + description = self.server.reader.describe() + description["operations"]["analysis"] = self.server.analysis_execution_enabled + self._json(description) elif parsed.path.startswith("/api/goal-source/"): if query.get("source_namespace") != [self.server.source.namespace]: self._json({"ok": False, "error": "Source identity changed; read its description again."}, 409) @@ -120,9 +171,15 @@ def do_GET(self): elif parsed.path == "/api/chat/explore-results" and query.get("view") == ["history"]: cursor = query.get("cursor", [""])[0] self._json(self.server.reader.history(goal_id, cursor)) - elif parsed.path == "/api/chat/research-analysis": - self.server.source.check_access(goal_id) - self._json({"ok": False, "error": "此运行来源尚未绑定分析包。"}, 503) + elif parsed.path in {RESEARCH_ANALYSIS_PATH, RESEARCH_ANALYSIS_PATH + "/citation"}: + if self.server.analysis_execution_enabled: + self._research_analysis_read() + else: + self.server.source.check_access(goal_id) + if parsed.path == RESEARCH_ANALYSIS_PATH: + self._json({"ok": False, "error": "此运行来源尚未绑定分析包。"}, 503) + else: + self._json({"ok": False, "error": "Read endpoint is not available for this source."}, 404) else: self._json({"ok": False, "error": "Read endpoint is not available for this source."}, 404) except SourceScopeError: @@ -155,6 +212,8 @@ def main(): parser.add_argument("--sources", type=Path, required=True, help="Owner-prepared Goal source manifest") parser.add_argument("--assets-dir", type=Path, help="Explicit custom/prototype assets, as in loopx chat") parser.add_argument("--port", type=int, default=0, help="First loopback port; default allocates temporary ports") + parser.add_argument("--analysis-runtime-root", type=Path, + help="Explicit local analysis storage and Codex execution; never writes to the original Goal source") args = parser.parse_args() assets = (args.assets_dir or Path(__file__).resolve().parents[1] / "web" / "chat").expanduser().resolve() if args.assets_dir is None: @@ -175,7 +234,8 @@ def main(): parser.error("Choose unprivileged ports for all readers") if len({s.id for s in sources}) != len(sources): parser.error("Source ids must be unique") - servers = [RuntimeWorkspaceServer(source, assets, port=args.port + i if args.port else 0) + servers = [RuntimeWorkspaceServer(source, assets, port=args.port + i if args.port else 0, + analysis_runtime_root=args.analysis_runtime_root) for i, source in enumerate(sources)] catalog = [{"id": s.source.id, "label": s.source.label, "status_url": s.status_url} for s in servers] threads = [] @@ -186,6 +246,7 @@ def main(): threads.append(thread) print(json.dumps({"ok": True, "read_only": True, "sources": [{**item, "namespace": server.source.namespace, + **({"analysis_enabled": True} if server.analysis_execution_enabled else {}), "access_url": f"http://127.0.0.1:{server.server_port}/api/goal-source", "url": f"http://127.0.0.1:{server.server_port}/chat/?" + urlencode({ **({"goalId": server.source.goal_id} if server.source.goal_id else {}), diff --git a/packages/dsh-loopx-plugin/pnpm-workspace.yaml b/packages/dsh-loopx-plugin/pnpm-workspace.yaml index bb4ce15f27..cf3a2d3d50 100644 --- a/packages/dsh-loopx-plugin/pnpm-workspace.yaml +++ b/packages/dsh-loopx-plugin/pnpm-workspace.yaml @@ -1,3 +1,11 @@ +packages: + - '.' + +# pnpm 9 reads the package.json projection; newer pnpm reads this one. +# The frozen-version matrix keeps both consistent with the existing lock. +overrides: + '@deepseek-ai/libreoffice-kit>fflate': 0.8.3 + allowBuilds: '@deepseek-ai/dsh-subprocess-local': false '@google/genai': false diff --git a/pyproject.toml b/pyproject.toml index dbe072b74b..c6123f2965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ include = ["loopx*"] "loopx.semantics" = ["*.json"] "loopx.control_plane.agents" = ["*.ts"] "loopx.control_plane.capabilities" = ["*.ts"] -"loopx.control_plane.collaboration" = ["*.ts"] +"loopx.control_plane.collaboration" = ["*.ts", "*.json"] "loopx.control_plane.coordination" = ["*.ts", "*.json"] "loopx.control_plane.goals" = ["*.ts"] "loopx.control_plane.goals.goal_frontier" = ["*.ts"] diff --git a/scripts/review_sla_report.py b/scripts/review_sla_report.py index e89430291f..8a0a57cf43 100644 --- a/scripts/review_sla_report.py +++ b/scripts/review_sla_report.py @@ -42,7 +42,7 @@ # .github/GOVERNANCE.md. Only these accounts can satisfy the published # first-response target; tests/test_review_sla_report.py checks this list # against that table. -DEFAULT_RESPONDERS = ("huangruiteng", "steven-kid", "maxliux5", "loopx-agent") +DEFAULT_RESPONDERS = ("huangruiteng", "steven-kid", "maxliux5", "loopx-agent", "songoow") # Fallback only for inputs that carry no actor type (an old --input file). # GitHub's typed actors are authoritative: a Bot is never a responder even # when its login looks human, and a human whose login starts with "copilot" diff --git a/skills/loopx-self-repair/SKILL.md b/skills/loopx-self-repair/SKILL.md index dd4e49a448..fe78edc1d6 100644 --- a/skills/loopx-self-repair/SKILL.md +++ b/skills/loopx-self-repair/SKILL.md @@ -141,6 +141,15 @@ replan, or terminal closeout must return to the strict semantic checkpoint. - Do not solve contradictory payloads by guessing. If `recommended_action`, `goal_boundary.write_scope`, todos, and interaction contract disagree, treat that as a projection bug or state authoring bug first. +- Separate priority advice from authority. Use the planning horizon and current + user direction to choose useful in-scope work; do not treat P0/P1 order as a + permission gate or relabel every task to escape it. Before explaining a + refusal as recovery protection, check whether a settlement identity is + actually bound. Priority-only rejection of an eligible explicit choice is a + control-plane defect. Preserve real gates and record the changed route in + existing Todo/vision state. A committed Turn may finish with progress while + its Todo stays open; settle and release before switching, rather than treating + the binding as an obligation to finish that entire Todo first. - Do not let fallback work hide the primary blocker. When a higher-priority path is gated but safe fallback is valid, report both the concrete gate and the fallback progress. diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index eba00ab5f2..6636f29d49 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -100,7 +100,7 @@ teaches a reusable control-plane lesson. | `content_layout_sequence_acceptance_gap` | A carousel passes independent per-page density checks, but the cover is not the density peak or interior pages remain visibly sparse. | Typed layout plan, selected template `page_sequence`, renderer measurement packet, per-page densities, and `layout-check` failures. | Acceptance modeled each page in isolation and omitted sequence-level cover prominence and interior compactness defaults. | Keep project-specific narrative rules in the writing adapter, but make built-in templates require a cover first, a first-page density maximum, and an interior density floor; lock low-interior and denser-than-cover cases with focused negative smokes. | | `shared_adapter_local_path_leak` | A shared adapter projection, such as Lark Kanban sync, would write an absolute local checkout, runtime, active-state, raw-log, or artifact path into a remote/shared row. | adapter dry-run payload, generated record values, docs boundary section, public/private scan. | Adapter reused local executor context as shared control-plane metadata. | Strip or replace the local path at the adapter projection layer, add a focused smoke that rejects the path, and only store explicit worker launch context when the row was authored for that purpose. | | `peer_workspace_boundary_gap` | A peer edits or prepares repository changes from a non-git directory, unrelated worktree, or checkout that violates the selected task/repository isolation policy; a cross-repository repair may also be misclassified against the goal repo. | `agent_identity`, selected todo `task_repository` and write scopes, goal `repo`, current git origin/worktree/root, quota payload, heartbeat prompt commands, AGENTS worktree policy. | Workspace safety was inferred from identity labels, prompt text, or the goal repo alone instead of the selected task and `quota should-run`. | Add or repair `agent_workspace_guard_v1`; use a credential-free first-class `task_repository` only for genuine cross-repository tasks, and require a matching linked worktree. Block delivery and spend until the peer moves to a compliant worktree and reruns the guard with the same `--agent-id`. Keep the goal repo fallback and do not treat the field as scope or write authority. Read-only and monitor-only work should not be blocked merely by identity. | -| `workspace_guard_action_selection_projection_gap` | A foreign default Todo hides eligible alternatives, or an explicit choice repeatedly reports `control_repair` while unselected reentry restores a different default. | Initial and explicit selection qualification, Todo priorities and ready deferred successors, workspace guard, action portfolio, and receipt identity. | Workspace repair may hide candidate selection; alternatively, a refused explicit choice leaves no selected Todo and the legacy guard fallback overwrites the typed refusal with a repair for the default. | Preserve alternative selection when otherwise eligible. If selection is already deferred or rejected with no selected Todo, preserve that refusal without guarding the default. Resolve the named preemption, then explicitly select an eligible Todo; revalidate its exact workspace before binding. Keep priority and repository isolation intact. | +| `workspace_guard_action_selection_projection_gap` | A foreign default Todo hides eligible alternatives, or an explicit choice repeatedly reports `control_repair` while unselected reentry restores a different default. | Initial and explicit selection qualification, Todo priorities and ready deferred successors, workspace guard, action portfolio, and receipt identity. | Workspace repair may hide candidate selection; alternatively, a refused explicit choice leaves no selected Todo and the legacy guard fallback overwrites the typed refusal with a repair for the default. | Preserve alternative selection when otherwise eligible. If selection is already deferred or rejected with no selected Todo, preserve that refusal without guarding the default. Separate hard authority/resume/lease gates from priority advice. A ready higher-priority deferred successor must not veto an otherwise eligible explicit choice. Keep the default recommendation and planning context, repair a priority-only refusal, then revalidate the chosen workspace before binding. An unbound choice is not protected by execution identity. | | `boundary_repair_action_selection_projection_gap` | Quota selects a Todo whose required write scopes are absent from the current Goal boundary, emits `boundary_projection_repair`, and binds that blocked Todo even though another eligible Todo is runnable; same-turn selection of the alternative then fails with a receipt identity conflict. | Initial and explicitly selected `quota should-run` packets, Goal write scope, selected Todo write scopes, action portfolio, interaction selection command, and append-only heartbeat receipt events. | Boundary repair correctly blocked the default delivery, but the planning gate treated self-repair as if no eligible alternative could be selected, so recommendation order became hidden settlement authority. | Keep boundary repair fail-closed for the mismatched Todo while projecting the typed action portfolio when alternatives exist. Commit the first guard identity-less, revalidate an explicit choice against the live boundary, and append one deterministic identity upgrade without rewriting the original receipt. Retain hard conflicts for genuinely bound settlements and cover the full CLI round trip. | | `monitor_successor_routing_gap` | A material monitor poll creates a natural-language `advance` successor for another repository, but the child has no repository, action, continuation, capabilities, stable target, or creation receipt; quota then routes plausible work without a safe workspace contract. | Parent monitor metadata, `quota monitor-poll` argv and artifact, child Todo metadata, rollout event successor ids, selected Todo workspace guard. | The agent extended a bounded observation into standing work, while the monitor-poll successor API accepted only text and an optional claimer and discarded typed routing. | Retire the unintended monitor/vision. Require explicit action semantics for every monitor-generated agent successor and explicit successor repository when the source monitor is repository-bound; carry optional execution capabilities, default continuation to `independent_handoff`, and derive a stable transition target when none is supplied. Record compact successor ids and routing receipts in the monitor artifact and rollout event. External-repository work must re-enter through that typed Todo rather than prior observation prose. | | `delivery_workspace_causality_projection_gap` | A validated delivery is written in one worktree, but `refresh-state` or `spend-slot` is attempted from the goal repo, a canonical checkout, or another repository; the workspace guard correctly rejects accounting and the agent must discover the right invocation by trial and error. | Latest unspent accountable run `delivery_workspace`, selected Todo `task_repository`, refresh command cwd or `--delivery-workspace-path`, spend command cwd, `workspace_guard`, and the generated `interaction_contract.cli_channel`. | The fail-closed accounting guard owns the invariant, but the machine interaction contract exposed only generic refresh/spend commands and omitted the causal workspace requirements. | Preserve the guard. For spend-capable selected Todos with an explicit `task_repository`, project compact `delivery_workspace_causality_v0`: refresh from the producing workspace or pass its explicit delivery path, then spend from the recorded accountable workspace. Omit it from default and no-spend hot paths. | diff --git a/tests/control_plane/test_inbox_read_recovery.py b/tests/control_plane/test_inbox_read_recovery.py new file mode 100644 index 0000000000..ee78226ac4 --- /dev/null +++ b/tests/control_plane/test_inbox_read_recovery.py @@ -0,0 +1,140 @@ +"""A terminated native reader can replay a partially supplied page.""" +import json +import subprocess +import time +from pathlib import Path + +from loopx.control_plane.collaboration.goal_instance_scope import collaboration_goal_scope +from loopx.control_plane.collaboration.inbox import _root, pending +from loopx.control_plane.collaboration.peers import request +from loopx.control_plane.projects.registry_codec import source_session_registry_transaction +from loopx.control_plane.effect_runtime import _node_executable +from loopx.file_lock import exclusive_cross_runtime_file_lock + + +def test_interrupted_page_preserves_first_receipt_and_finishes_remaining_reads(tmp_path): + registry = tmp_path / "registry.json" + with source_session_registry_transaction(registry, operation="create_inbox_fixture", create=lambda: { + "profile_id": "source_session_v1", "goals": [{"id": "research", "repo": str(tmp_path), + "status": "active", "goal_instance_id": "ginst_" + "a" * 32, + "coordination": {"registered_agents": ["source", "worker"]}}], + "session_bindings": [], "session_receipts": [], "lifetime_receipts": [], + }) as transaction: + transaction.commit(transaction.payload_copy()) + brief = {"schema_version": "collaboration_brief_v0", "purpose": "Inspect the current artifact", + "context": "Keep existing authority", "constraints": [], "inputs": [], + "acceptance": ["Return the findings"], "return_requirement": "Reply to the requester"} + for operation in ("first", "second"): + request(tmp_path, registry, "research", "source", "worker", operation, brief) + owner = _root(tmp_path) + before = {p: p.read_bytes() for p in (owner / "entries").rglob("*.json")} + module = (Path(__file__).resolve().parents[2] / "loopx/control_plane/collaboration/inbox_read.ts").as_uri() + script = f"""import {{readCollaborationInbox}} from {json.dumps(module)}; +let input = ''; for await (const chunk of process.stdin) input += chunk; +process.stdout.write(JSON.stringify(await readCollaborationInbox(JSON.parse(input))));""" + command = [_node_executable(), "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script] + with collaboration_goal_scope(registry, goal_id="research", agents=("worker",)) as scope: + items = pending(tmp_path, "research", "worker", scope=scope)["items"] + first = owner / "reads" / (items[0]["request_id"] + ".json") + second = owner / "reads" / (items[1]["request_id"] + ".json") + payload = {"operation": "record_read", "runtime_root": str(tmp_path), + "goal_id": "research", "agent_id": "worker", "items": items, "deliveries": [], + "scope": {"profile_id": scope.profile_id, "caller_goal_ref": scope.caller_goal_ref, + "current_goal_ref": scope.current_goal_ref}, "observed_at": "2026-10-19T00:00:00+00:00"} + with exclusive_cross_runtime_file_lock(owner / "request-locks" / items[1]["request_id"]): + child = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8") + try: + child.stdin.write(json.dumps(payload)) + child.stdin.close() + deadline = time.monotonic() + 15 + while not first.exists() and child.poll() is None and time.monotonic() < deadline: + time.sleep(0.02) + assert first.exists(), child.stderr.read() if child.poll() is not None else "first read was not committed" + assert not second.exists() + committed = first.read_bytes() + finally: + if child.poll() is None: + child.terminate() + child.wait(timeout=15) + child.stdout.close() + child.stderr.close() + payload["observed_at"] = "2026-10-19T00:00:01+00:00" + replay = subprocess.run(command, input=json.dumps(payload), capture_output=True, + text=True, encoding="utf-8", timeout=30) + assert replay.returncode == 0, replay.stderr + assert json.loads(replay.stdout)["read_receipts"] == [None, None] + assert first.read_bytes() == committed + assert json.loads(second.read_text(encoding="utf-8"))["read_at"] == payload["observed_at"] + assert {p: p.read_bytes() for p in before} == before + + +def test_large_legal_inbox_page_keeps_complete_source_and_records_supplied_reads(tmp_path): + from loopx.control_plane.collaboration.inbox import ( + ENTRY_SCHEMA, _hash, _write, normalize_request, normalize_source_context, plan_request_publication, + ) + from loopx.control_plane.collaboration.peers import read_inbox + from loopx.control_plane.effect_runtime import MAX_RESPONSE_BYTES + + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": "research", "repo": str(tmp_path), + "coordination": {"registered_agents": ["worker"]}}]}), encoding="utf-8") + message = normalize_source_context("😀" * 24_000) + target = {"goal_id": "research", "agent_id": "worker"} + normalized = normalize_request({**target, "brief": {"schema_version": "collaboration_brief_v0", + "purpose": "p" * 500, "context": "c" * 6000, "constraints": ["x" * 1000] * 8, + "inputs": [], "acceptance": ["a" * 1000], "return_requirement": "Return the findings"}}) + folder = _root(tmp_path) / "entries" / _hash(target) + for index in range(20): + source_id = f"synthetic-source:{index}" + rid = _hash([source_id, target]) + source = {"schema_version": ENTRY_SCHEMA, "request_id": rid, "source_id": source_id, + **normalized, "message": message} + publication = plan_request_publication(source, entry=None) + _write(folder / (rid + ".json"), publication["entry"]) + before = {p: p.read_bytes() for p in folder.glob("*.json")} + + response = read_inbox(tmp_path, registry, "research", "worker", workspace=tmp_path) + + assert len(response["items"]) == 20 + assert len(json.dumps(response, ensure_ascii=False).encode()) > MAX_RESPONSE_BYTES + assert all(item["message"] == message and item["brief"] == normalized["brief"] for item in response["items"]) + assert all(item["recorded_decision"] is None for item in response["items"]) + assert {p: p.read_bytes() for p in before} == before + assert len(list((_root(tmp_path) / "reads").glob("*.json"))) == 20 + + +def test_operation_source_appearing_after_committed_read_is_observed_on_next_read(tmp_path, monkeypatch): + import pytest + from loopx.chat_store import ChatSessionStore + from loopx.capabilities.manager_context import deliver + from loopx.control_plane.collaboration import inbox_read + from loopx.control_plane.collaboration.peers import read_inbox + + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": "research", "repo": str(tmp_path), + "coordination": {"registered_agents": ["worker"]}}]}), encoding="utf-8") + store = ChatSessionStore(tmp_path) + session = store.create_session(goal_id="loopx-manager", agent_id="codex", adapter_kind="codex_app_server", + upstream_thread_id="synthetic", channel_id="manager") + turn, _ = store.create_turn(session["session_id"], client_turn_id="source", message="Inspect the artifact", origin="web") + receipt = deliver(tmp_path, registry, session=session, turn=turn, + request={"goal_id": "research", "agent_id": "worker"}) + transport = inbox_read.read_inbox_transaction + + def source_appears(root, query, **kwargs): + response = transport(root, query, **kwargs) + if query["operation"] == "receiver_read": + path = root / "chat" / "actions" / "actions.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{broken", encoding="utf-8") + return response + + monkeypatch.setattr(inbox_read, "read_inbox_transaction", source_appears) + observed = read_inbox(tmp_path, registry, "research", "worker") + assert [item["request_id"] for item in observed["items"]] == [receipt["request_id"]] + reads = {p: p.read_bytes() for p in (_root(tmp_path) / "reads").glob("*.json")} + monkeypatch.setattr(inbox_read, "read_inbox_transaction", transport) + with pytest.raises(ValueError): + read_inbox(tmp_path, registry, "research", "worker") + assert {p: p.read_bytes() for p in reads} == reads diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index af472b4aaf..b123e588ff 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -4923,68 +4923,97 @@ def test_first_call_agent_selection_is_qualified_before_receipt_commit( assert _heartbeat_receipt_count(runtime, turn_instance_id) == 1 -@pytest.mark.parametrize("foreign_workspace", [False, True]) -def test_ready_deferred_priority_is_not_an_eligible_alternative( +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("reentry", [False, True]) +def test_ready_deferred_priority_advises_but_does_not_veto_agent_choice( tmp_path: Path, - foreign_workspace: bool, + reentry: bool, + provider: str, + monkeypatch: pytest.MonkeyPatch, ) -> None: - project, runtime, registry_path = _write_fixture(tmp_path / "portfolio") + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry_path = _write_fixture(tmp_path) _configure_selectable_alternative(project) _configure_ready_deferred_priority_preemption(project) - turn_instance_id = "turn-ready-deferred-priority-selection" + if provider != "legacy": + listed_rc, listed = _run_cli(registry_path, runtime, "todo", "list", "--goal-id", GOAL_ID) + assert listed_rc == 0, listed + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, handoff_mode="soft_claim", todos=listed["todos"], + ) + initialize_canonical_authority( + runtime, GOAL_ID, projection, provider=provider, + state_path=project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md", + ) guard_args = ( "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, - "--agent-id", AGENT_ID, "--turn-instance-id", turn_instance_id, + "--agent-id", AGENT_ID, "--turn-instance-id", "turn-priority-choice", "--scan-path", str(project), ) - first_rc, first = _run_cli(registry_path, runtime, *guard_args) - assert first_rc == 0, first - assert first["selected_todo"]["todo_id"] == TODO_ID - assert first["agent_todo_summary"]["current_agent_deferred_resume_count"] == 1 - suggested = first.get("action_portfolio", {}).get("suggested_actions", []) - assert ALTERNATIVE_TODO_ID not in {item["todo_id"] for item in suggested} + if reentry: + first_rc, first = _run_cli(registry_path, runtime, *guard_args) + assert first_rc == 0, first + assert first["selected_todo"]["todo_id"] == TODO_ID + assert first["agent_todo_summary"]["current_agent_deferred_resume_count"] == 1 + suggested = first["action_portfolio"]["suggested_actions"] + assert ALTERNATIVE_TODO_ID in {item["todo_id"] for item in suggested} + assert "settlement_identity" not in first["heartbeat_receipt"] - project, runtime, registry_path = _write_fixture(tmp_path / "selection") + selected_rc, selected = _run_cli( + registry_path, runtime, *guard_args, "--todo-id", ALTERNATIVE_TODO_ID + ) + assert selected_rc == 0, selected + assert selected["selected_todo"]["todo_id"] == ALTERNATIVE_TODO_ID + assert selected["action_selection_qualification"]["state"] == "qualified" + assert selected["normal_delivery_allowed"] is True + assert selected["heartbeat_receipt"]["settlement_identity"]["todo_id"] == ALTERNATIVE_TODO_ID + # The higher-priority deferred work remains visible and is not reopened, + # completed, or reprioritized merely to make the chosen work executable. + assert selected["agent_todo_summary"]["current_agent_deferred_resume_count"] == 1 + assert not selected.get("agent_scope_frontier", {}).get("priority_preemption") + + +@pytest.mark.parametrize("foreign_workspace", [False, True]) +def test_explicit_choice_survives_default_successor_replan_but_not_workspace_gate( + tmp_path: Path, foreign_workspace: bool, +) -> None: + project, runtime, registry_path = _write_fixture(tmp_path) _configure_selectable_alternative(project) _configure_ready_deferred_priority_preemption(project) + state_path = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + # Without an open P0 the default recommends resolving the deferred P0. + state_path.write_text(state_path.read_text(encoding="utf-8").replace( + "[P0] Validate and settle", "[P1] Validate and settle", + ), encoding="utf-8") if foreign_workspace: registry = json.loads(registry_path.read_text(encoding="utf-8")) registry["goals"][0]["coordination"]["registered_agents"].append("codex-peer") registry_path.write_text(json.dumps(registry), encoding="utf-8") - state_path = _configure_repository_write_todo(project) - state_path.write_text( - state_path.read_text(encoding="utf-8").replace( - "action_kind=validate", "action_kind=implement" - ), encoding="utf-8", - ) - selection_args = ( - "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, - "--agent-id", AGENT_ID, - "--turn-instance-id", "turn-ready-deferred-explicit-selection", - "--scan-path", str(project), - ) - blocked_rc, blocked = _run_cli( - registry_path, runtime, *selection_args, "--todo-id", ALTERNATIVE_TODO_ID - ) - assert blocked_rc == 1, blocked - assert blocked["error_code"] == "quota_action_selection_deferred" - assert blocked["action_selection_qualification"]["reason"] == ( - "ready_deferred_successor_priority_preemption" - ) - assert "settlement_identity" not in blocked["heartbeat_receipt"] - _assert_action_selection_recovery_projections(blocked) - assert "workspace_guard" not in blocked - - selected_rc, selected = _run_cli( - registry_path, runtime, *selection_args, "--todo-id", TODO_ID + state_path.write_text(state_path.read_text(encoding="utf-8").replace( + f"todo_id={ALTERNATIVE_TODO_ID} status=open", + f"todo_id={ALTERNATIVE_TODO_ID} status=open " + "task_repository=git:example.test/team/another-repository " + "required_write_scopes=src%2F%2A%2A", + ), encoding="utf-8") + rc, selected = _run_cli( + registry_path, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--turn-instance-id", "turn-explicit-over-default-replan", + "--scan-path", str(project), "--todo-id", ALTERNATIVE_TODO_ID, ) - assert selected_rc == 0, selected - assert selected["selected_todo"]["todo_id"] == TODO_ID + assert rc == 0, selected + assert selected["selected_todo"]["todo_id"] == ALTERNATIVE_TODO_ID + assert selected["heartbeat_receipt"]["settlement_identity"]["todo_id"] == ALTERNATIVE_TODO_ID + assert selected["normal_delivery_allowed"] is (not foreign_workspace) if foreign_workspace: assert selected["effective_action"] == "agent_workspace_repair" - assert selected["normal_delivery_allowed"] is False assert selected["workspace_guard"]["blocks_delivery"] is True - assert selected["heartbeat_receipt"]["settlement_identity"]["todo_id"] == TODO_ID + else: + assert selected["effective_action"] == "normal_run" def test_pending_deferred_p0_allows_independent_p1_selection( diff --git a/tests/control_plane_ts/inbox_read_transaction.test.ts b/tests/control_plane_ts/inbox_read_transaction.test.ts new file mode 100644 index 0000000000..6aeeee0ce3 --- /dev/null +++ b/tests/control_plane_ts/inbox_read_transaction.test.ts @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync} from "node:fs"; +import {join} from "node:path"; +import {tmpdir} from "node:os"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {readCollaborationInbox} from "../../loopx/control_plane/collaboration/inbox_read.ts"; + +const ref = {goal_id: "research", goal_instance_id: "ginst_" + "a".repeat(32)}; +// Addresses captured from the shipped Python storage codec, including its spaces. +const targets = { + legacy: "433a92de158f221e8f912110b3736ae8aca7f605fc96be50019e32311407e69b", + exact: "f1f1d6040c09e2d54ce7ba2d6ec93659c1bfdf365a2b072bf40d38da0a8e52d8", +}; +function fixture(t: test.TestContext, profile: keyof typeof targets = "exact") { + const root = mkdtempSync(join(tmpdir(), "loopx-inbox-来源-")); + t.after(() => rmSync(root, {recursive: true, force: true})); + const base = join(root, ".local", "manager-context"); + const folder = join(base, "entries", targets[profile]); + mkdirSync(folder, {recursive: true}); + const scope = {profile_id: profile === "exact" ? "source_session_v1" : null, + caller_goal_ref: profile === "exact" ? ref : null, current_goal_ref: profile === "exact" ? ref : null}; + const row = (index: number, extra: JsonObject = {}) => ({ + schema_version: profile === "exact" ? "loopx_manager_context_entry_v2" : "loopx_manager_context_entry_v1", + goal_id: "research", agent_id: "worker", request_id: index.toString(16).padStart(64, "0"), + ...(profile === "exact" ? {goal_ref: ref} : {}), source_id: "source:" + index, + message: "Inspect the current artifact. " + "x".repeat(300) + "PRIVATE_LONG_SOURCE", + ...extra, + }); + const publish = (index: number, extra: JsonObject = {}) => { + const request = row(index, extra); + writeFileSync(join(folder, request.request_id + ".json"), JSON.stringify(request)); + return request; + }; + const call = (operation: string, extra: JsonObject = {}) => readCollaborationInbox({runtime_root: root, + scope, goal_id: "research", agent_id: "worker", operation, + observed_at: "2026-10-19T00:00:00.000000+00:00", read_command: "loopx manager-inbox read", ...extra}); + return {root, base, folder, row, publish, call, scope}; +} +function sourceBytes(folder: string): [string, string][] { + return readdirSync(folder).sort().map(name => [name, readFileSync(join(folder, name)).toString("hex")]); +} + +for (const profile of ["legacy", "exact"] as const) { + test(`${profile}: Turn observation retains full counts without writing or exposing long source`, async t => { + const f = fixture(t, profile); + for (let n = 1; n <= 128; n++) f.publish(n); + const before = sourceBytes(f.folder), observed = await f.call("turn_start"); + const page = observed.result as JsonObject; + assert.equal((page.items as unknown[]).length, 20); + assert.equal(page.has_more, true); + const context = (observed.context as JsonObject).unfinished_delegations as JsonObject; + assert.equal(context.incoming_count, 128); + assert.equal(context.omitted_count, 108); + assert.equal(context.scan_complete, true); + assert.equal(JSON.stringify(context).includes("PRIVATE_LONG_SOURCE"), false); + assert.deepEqual(sourceBytes(f.folder), before); + assert.deepEqual(readdirSync(f.base).sort(), ["entries"]); + }); + + test(`${profile}: a completed receiver page records only its supplied requests and replays without adoption`, async t => { + const f = fixture(t, profile); + for (let n = 1; n <= 26; n++) f.publish(n); + const source = sourceBytes(f.folder), first = await f.call("receiver_read"); + assert.equal(first.completed, true); + assert.equal((first.followthrough as JsonObject[])[0].step, "assess_request"); + const reads = join(f.base, "reads"), before = sourceBytes(reads).filter(([name]) => name.endsWith(".json")); + assert.equal(before.length, 20); + for (const [name] of before) assert.equal(JSON.parse(readFileSync(join(reads, name), "utf8")).kind, "receiver_cli_read"); + const repeated = await f.call("receiver_read"); + assert.equal((repeated.result as JsonObject).has_more, true); + assert.deepEqual(sourceBytes(reads).filter(([name]) => name.endsWith(".json")), before); + assert.deepEqual(sourceBytes(f.folder), source); + assert.equal((repeated.context as JsonObject).read_complete, false); + }); +} + +test("a later damaged entry cannot block the complete current page", async t => { + const f = fixture(t); + for (let n = 1; n <= 21; n++) f.publish(n); + writeFileSync(join(f.folder, "f".repeat(64) + ".json"), "{broken"); + const first = (await f.call("pending")).result as JsonObject; + assert.equal((first.items as unknown[]).length, 20); + await assert.rejects(f.call("pending", {cursor: first.next_cursor}), /invalid manager context record/); + const context = (await f.call("turn_context")).unfinished_delegations as JsonObject; + assert.equal(context.incoming_count, 21); + assert.equal(context.unavailable_count, 1); + assert.equal(context.scan_complete, false); +}); + +test("named reads never fall back to another page or record unrelated requests", async t => { + const f = fixture(t), original = f.publish(1), other = f.publish(2); + const result = await f.call("receiver_read", {request_id: original.request_id}); + assert.equal((result.result as JsonObject).request_id, original.request_id); + assert.equal((result.result as JsonObject).peer_returns, undefined); + assert.equal(result.context, undefined); + assert.deepEqual(readdirSync(join(f.base, "reads")).filter(name => name.endsWith(".json")), [original.request_id + ".json"]); + await assert.rejects(f.call("receiver_read", {request_id: other.request_id, cursor: "invalid"}), /cannot use inbox cursors/); + await assert.rejects(f.call("receiver_read", {request_id: "f".repeat(64)}), /not found/); + assert.deepEqual(readdirSync(join(f.base, "reads")).filter(name => name.endsWith(".json")), [original.request_id + ".json"]); +}); + +test("a stale Goal instance cannot acknowledge an old request", async t => { + const f = fixture(t), request = f.publish(1), before = sourceBytes(f.folder); + await assert.rejects(f.call("record_read", {items: [request], deliveries: [], + scope: {...f.scope, current_goal_ref: {...ref, goal_instance_id: "ginst_" + "b".repeat(32)}}}), /lifecycle rejected/); + assert.deepEqual(sourceBytes(f.folder), before); + assert.deepEqual(readdirSync(f.base), ["entries"]); +}); + +for (const field of ["goal_id", "agent_id"]) { + test(`a supplied request cannot change its ${field} while reusing an existing id`, async t => { + const f = fixture(t, "legacy"), row = f.publish(1); + await assert.rejects(f.call("record_read", {items: [{...row, [field]: "other"}], deliveries: []}), /scope mismatch/); + assert.deepEqual(readdirSync(f.base), ["entries"]); + }); +} + +test("input-provider work defers receipts until finalization", async t => { + const f = fixture(t), request = f.publish(1, {brief: {inputs: [{ref: "artifact.txt"}]}}); + const prepared = await f.call("receiver_read"); + assert.equal(prepared.completed, undefined); + assert.deepEqual(readdirSync(f.base), ["entries"]); + const final = await f.call("record_read", {items: [request], deliveries: prepared.deliveries, + work_observations: [{kind: "pending", recorded_decision: null, linked_todos: [], evidence_refs: [], evidence_unavailable: false}], + include_context: true}); + assert.equal((final.followthrough as JsonObject[])[0].step, "assess_request"); + assert.equal(((final.context as JsonObject).unfinished_delegations as JsonObject).incoming_count, 1); + assert.equal(JSON.parse(readFileSync(join(f.base, "reads", request.request_id + ".json"), "utf8")).kind, "receiver_cli_read"); +}); + +for (const bytes of [Buffer.from("\ufeff{}"), Buffer.from([0xff]), Buffer.alloc(128_001, 32)]) { + test(`unreadable record bytes remain visible as incomplete evidence (${bytes.length})`, async t => { + const f = fixture(t), request = f.publish(1); + writeFileSync(join(f.folder, request.request_id + ".json"), bytes); + await assert.rejects(f.call("receiver_read"), /manager context record/); + const context = (await f.call("turn_context")).unfinished_delegations as JsonObject; + assert.equal(context.unavailable_count, 1); + assert.equal(context.scan_complete, false); + assert.deepEqual(readdirSync(f.base), ["entries"]); + }); +} + +test("malformed receipts retain the request and recovery advice", async t => { + const f = fixture(t), request = f.publish(1); + mkdirSync(join(f.base, "decisions")); + writeFileSync(join(f.base, "decisions", request.request_id + ".json"), "{broken"); + const response = await f.call("receiver_read"), item = ((response.result as JsonObject).items as JsonObject[])[0]; + assert.equal(item.inbox_state, "receipt_unavailable"); + assert.equal(item.recorded_decision, null); + assert.equal((response.followthrough as JsonObject[])[0].step, "recover_evidence"); + assert.equal(readFileSync(join(f.base, "decisions", request.request_id + ".json"), "utf8"), "{broken"); +}); + +test("unreadable source content is incomplete evidence rather than coerced prose", async t => { + const f = fixture(t); + f.publish(1, {message: {text: "unreadable"}}); + await assert.rejects(f.call("receiver_read"), /invalid Inbox source content/); + const context = (await f.call("turn_context")).unfinished_delegations as JsonObject; + assert.equal(context.scan_complete, false); + assert.equal(context.unavailable_count, 1); + assert.deepEqual(context.items, []); + assert.deepEqual(readdirSync(f.base), ["entries"]); +}); + +for (const status of [null, ["queued"]]) { +test(`malformed delivery status retains its recovery distinction (${JSON.stringify(status)})`, async t => { + const f = fixture(t), request = f.publish(1), folder = join(f.base, "replies", request.request_id); + mkdirSync(folder, {recursive: true}); + mkdirSync(join(f.base, "decisions")); + const identity = Object.fromEntries(["request_id", "goal_id", "agent_id", "source_id", "goal_ref"].map(key => [key, request[key as keyof typeof request]])); + writeFileSync(join(f.base, "decisions", request.request_id + ".json"), JSON.stringify({...identity, decision: "adopt"})); + writeFileSync(join(folder, "conclusion.json"), JSON.stringify({...identity, phase: "conclusion", decision: "adopt", text: "Findings"})); + writeFileSync(join(folder, "conclusion.delivery.json"), JSON.stringify({status})); + const context = (await f.call("turn_context")).unfinished_delegations as JsonObject; + const agenda = context.items as JsonObject[]; + if (status === null) { + assert.equal((agenda[0].returns as JsonObject[])[0].status, "explicit_unverified"); + assert.equal(agenda[0].step, "await_return_delivery"); + } else assert.equal(agenda[0].step, "recover_evidence"); +}); +} + +test("a legal combined Inbox and peer-return page retains content beyond the inline wire budget", async t => { + const f = fixture(t), message = "x".repeat(50_000), text = "😀".repeat(20_000); + const operations = join(f.base, "peer-operations", targets.exact); + const receiver = join(f.base, "entries", "5ab0deb53efce4b1d0cf830afb67ce662e0e9aff3127e2424a4ce768212b5586"); + for (const folder of [operations, receiver, join(f.base, "decisions"), join(f.base, "roundtrips")]) mkdirSync(folder, {recursive: true}); + for (let n = 1; n <= 20; n++) { + f.publish(n, {message}); + const row = f.row(100 + n, {agent_id: "reviewer", source_kind: "peer", source_agent_id: "worker", + brief: {schema_version: "collaboration_brief_v0", purpose: "Independent review", context: "Inspect the artifact", + constraints: [], inputs: [], acceptance: ["Return findings"], return_requirement: "Reply to the requester"}}); + const identity = Object.fromEntries(["request_id", "goal_id", "agent_id", "source_id", "goal_ref"].map(key => [key, row[key as keyof typeof row]])); + writeFileSync(join(receiver, row.request_id + ".json"), JSON.stringify(row)); + writeFileSync(join(operations, row.request_id + ".json"), JSON.stringify(row)); + writeFileSync(join(f.base, "roundtrips", row.request_id + ".json"), JSON.stringify(identity)); + writeFileSync(join(f.base, "decisions", row.request_id + ".json"), JSON.stringify({...identity, decision: "adopt"})); + const replies = join(f.base, "replies", row.request_id); + mkdirSync(replies, {recursive: true}); + writeFileSync(join(replies, "conclusion.json"), JSON.stringify({...identity, phase: "conclusion", + decision: "adopt", text, created_at: "2026-10-19T00:00:00Z"})); + } + const observed = await f.call("receiver_read"), page = observed.result as JsonObject; + assert.equal(observed.completed, true); + assert.equal(Buffer.byteLength(JSON.stringify(observed)) > 2 * 1024 * 1024, true); + assert.equal((page.items as JsonObject[]).length, 20); + assert.equal(((page.peer_returns as JsonObject).items as JsonObject[]).length, 20); + for (const item of page.items as JsonObject[]) assert.equal(item.message, message); + for (const item of (page.peer_returns as JsonObject).items as JsonObject[]) assert.equal(item.text, text); +}); + +test("a large private agenda lets another runtime request finish before the scan completes", async t => { + const large = fixture(t), other = fixture(t); + for (let n = 1; n <= 256; n++) large.publish(n); + let finished = false; + const scanning = large.call("turn_context").then(context => {finished = true; return context;}); + const independent = await other.call("turn_context"); + assert.equal((independent.unfinished_delegations as JsonObject).incoming_count, 0); + assert.equal(finished, false); + assert.equal(((await scanning).unfinished_delegations as JsonObject).incoming_count, 256); +}); diff --git a/tests/extensions/test_lark_goal_topic_connections.py b/tests/extensions/test_lark_goal_topic_connections.py index fb823c1667..8627d35c6a 100644 --- a/tests/extensions/test_lark_goal_topic_connections.py +++ b/tests/extensions/test_lark_goal_topic_connections.py @@ -176,6 +176,45 @@ def test_lists_apps_with_readiness_without_returning_secrets() -> None: assert all("app_id" not in app for app in apps) +def test_profile_catalog_never_verifies_or_infers_health() -> None: + state: dict[str, Any] = {} + apps = list_lark_apps(runner=_runner(state), cli_bin="fake-lark", verify=False) + assert len(state["calls"]) == 1 + assert {app["app_ref"] for app in apps} == {"mew", "standby"} + assert all(not app["ready"] and not app["reply_ready"] for app in apps) + assert all(app["health_error_code"] == "lark_app_unverified" for app in apps) + # A selected observation re-enumerates the current profile and checks only it. + state["calls"].clear() + observed = list_lark_apps(runner=_runner(state), cli_bin="fake-lark", app_ref="mew") + assert len(observed) == 1 and observed[0]["ready"] + assert len(state["calls"]) == 2 + assert not any("standby" in call for call in state["calls"]) + with pytest.raises(ValueError, match="no longer configured"): + list_lark_apps(runner=_runner(state), cli_bin="fake-lark", app_ref="removed") + + +@pytest.mark.parametrize("payload,code", [(None, 0), ({}, 0), ([], 1)]) +def test_profile_read_failure_is_not_an_empty_catalog(payload: Any, code: int) -> None: + def runner(*_args: Any) -> dict[str, Any]: + return {"returncode": code, "stdout": json.dumps(payload), "stderr": "private diagnostic"} + with pytest.raises(RuntimeError, match="Could not read local Lark profiles"): + list_lark_apps(runner=runner, verify=False) + + +def test_profile_identity_mismatch_is_not_ready() -> None: + state: dict[str, Any] = {} + original = _runner(state) + def runner(args: list[str], cwd: Any, timeout: Any) -> dict[str, Any]: + result = original(args, cwd, timeout) + if "auth" in args: + value = json.loads(result["stdout"]) + value["appId"] = "cli_different_app" + result["stdout"] = json.dumps(value) + return result + app = list_lark_apps(runner=runner, app_ref="mew")[0] + assert not app["ready"] and app["health_error_code"] == "lark_app_unverified" + + def test_connections_for_two_agents_coexist(tmp_path: Path) -> None: registry = _registry(tmp_path) registry["goals"][0]["coordination"] = { diff --git a/tests/presentation/test_research_analysis_api.py b/tests/presentation/test_research_analysis_api.py index 5e83c008c4..396c31d8ab 100644 --- a/tests/presentation/test_research_analysis_api.py +++ b/tests/presentation/test_research_analysis_api.py @@ -4,6 +4,7 @@ No active Goal, solver state, model account or evaluator is touched. """ from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager import hashlib import json import os @@ -18,6 +19,8 @@ from loopx.capabilities.benchmark_toolkit.trajectory_assessment_store import register_pack from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.control_plane.status.goal_source import GoalSource +from loopx.presentation.runtime_workspace import RuntimeWorkspaceServer @pytest.fixture @@ -173,3 +176,184 @@ def test_native_outcomes_and_bound_citations_read_over_http_without_analysis(wor assert not w["count"].exists() with pytest.raises(HTTPError): w["request"](goal="other-owner", pack_id=registered["pack_id"]) + + +@contextmanager +def source_analysis_workspace(w, name, *, enabled=True): + """Synthetic source transport; actual local store, HTTP and provider process.""" + class Source(GoalSource): + failed = False + registered = True + reads = 0 + + def __init__(self): + super().__init__(name, name, "/registry", "/runtime", "/cli", "/python", + "/bin", "local", "host", "research-owner") + + def read_status(self, goal_id=None): + self.reads += 1 + self.check_access(goal_id) + if self.failed: + raise OSError("Private source diagnostic") + return {"ok": True, "run_history": {"goals": [{"id": self.goal_id, + "registry_member": self.registered}]}} + + server = RuntimeWorkspaceServer(Source(), w["tmp_path"], + analysis_runtime_root=w["tmp_path"] / "source-analysis" if enabled else None) + data = json.loads((w["tmp_path"] / "input.json").read_text()) + data["scope"].update(goal_id="research-owner", case_id=name) + path = w["tmp_path"] / f"input-{name}.json" + path.write_text(json.dumps(data)) + pack = register_pack(runtime_root=server.analysis_runtime_root or w["tmp_path"] / "disabled", + owner_goal_id="research-owner", input_path=path, source_root=w["sources"], title=name) + operation = dict(goal_id="research-owner", pack_id=pack["pack_id"], analysis_key=pack["analysis_key"]) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + + def request(*, body=None, suffix="", namespace=server.source.namespace, method=None, headers=None, **query): + if namespace is not None: + query["source_namespace"] = namespace + if body is None: + query.setdefault("goal_id", "research-owner") + url = f"http://127.0.0.1:{server.server_port}/api/chat/research-analysis{suffix}?{urlencode(query)}" + req = Request(url, data=None if body is None else json.dumps(body).encode(), method=method, + headers={"Content-Type": "application/json", **(headers or {})}) + with urlopen(req, timeout=25) as response: + return json.load(response) + + try: + yield dict(request=request, operation=operation, server=server, input_path=path) + finally: + server.shutdown() + worker.join(timeout=5) + server.server_close() + + +def test_source_goal_analysis_reads_cancel_retry_and_reuse_do_not_mutate_source(workspace): + w = workspace + with source_analysis_workspace(w, "retained") as s: + read, operation = s["request"], s["operation"] + snapshot = read() + assert snapshot["execution_enabled"] is True + assert snapshot["pack"]["scope"]["goal_id"] == "research-owner" + assert snapshot["pack"]["job"] is None and not w["count"].exists() + with ThreadPoolExecutor(max_workers=1) as pool: + running = pool.submit(read, body=operation) + until = time.monotonic() + 8 + while time.monotonic() < until: + job = read()["pack"]["job"] + if job and job["status"] == "running": + break + time.sleep(.05) + else: + pytest.fail("Local source-analysis process did not start") + assert read(body=operation)["job"]["status"] == "busy" + assert read(body=operation, suffix="/cancel")["job"]["cancel_requested"] + assert running.result(timeout=8)["job"]["status"] == "cancelled" + w["mode"].write_text("ok") + assert read(body={**operation, "retry": True})["job"]["status"] == "succeeded" + view = read()["pack"] + assert view["result"]["assessments"][0]["verdict"] == "no" + calls = w["count"].read_bytes() + assert read(body=operation)["job"]["reused"] + assert w["count"].read_bytes() == calls + citation = dict(pack_id=operation["pack_id"], analysis_key=operation["analysis_key"], + source_id="facts", first_line=2, last_line=2) + assert read(suffix="/citation", **citation)["text"] == "Only the diagnostic was measured; native utility remains untested." + for path, original in w["before"].items(): + assert path.read_bytes() == original + (w["sources"] / "facts.txt").write_text("changed source") + assert read()["pack"]["applicability"] == "historical" + assert read(suffix="/citation", **citation)["text"] == "Only the diagnostic was measured; native utility remains untested." + with pytest.raises(HTTPError) as changed: + read(body=operation) + assert changed.value.code == 409 + + +def test_source_analysis_requires_exact_namespace_goal_and_live_membership(workspace): + w = workspace + with source_analysis_workspace(w, "retained") as a, source_analysis_workspace(w, "fresh") as b: + assert a["server"].analysis_runtime_root != b["server"].analysis_runtime_root + assert a["request"]()["pack"]["scope"]["case_id"] == "retained" + assert b["request"]()["pack"]["scope"]["case_id"] == "fresh" + for namespace in (None, "0" * 64, b["server"].source.namespace): + with pytest.raises(HTTPError): + a["request"](namespace=namespace) + with pytest.raises(HTTPError): + a["request"](namespace=namespace, body=a["operation"]) + for body in (a["operation"], {**b["operation"], "goal_id": "another-goal"}): + with pytest.raises(HTTPError): + b["request"](body=body) + with pytest.raises(HTTPError): + b["request"](pack_id=a["operation"]["pack_id"]) + source = a["server"].source + for registered, failed, code in ((False, False, 404), (True, True, 503)): + source.registered, source.failed = registered, failed + a["server"].reader._read_at = 0 + with pytest.raises(HTTPError) as denied: + a["request"](body=a["operation"]) + assert denied.value.code == code + source.registered, source.failed = True, False + a["server"].reader._read_at = 0 + assert a["request"]()["pack"]["scope"]["case_id"] == "retained" + assert not w["count"].exists() + + +@pytest.mark.parametrize("registered,failed,code", [(False, False, 404), (True, True, 503)]) +@pytest.mark.parametrize("retry", [False, True]) +def test_source_execution_rechecks_a_warm_source_before_start_or_retry( + workspace, monkeypatch, registered, failed, code, retry): + w = workspace + w["mode"].write_text("ok") + # Hold the display-cache clock inside its normal lifetime. Source facts + # change after a real GET; the test never alters private cache fields. + monkeypatch.setattr("loopx.control_plane.status.goal_source.monotonic", lambda: 100.0) + with source_analysis_workspace(w, "warm-source") as s: + read, operation, source = s["request"], s["operation"], s["server"].source + assert read()["pack"]["job"] is None + assert read()["pack"]["scope"]["goal_id"] == "research-owner" + assert source.reads == 1 # ordinary reads still share the display cache + source.registered, source.failed = registered, failed + with pytest.raises(HTTPError) as denied: + read(body={**operation, "retry": retry}) + assert denied.value.code == code + assert source.reads == 2 + assert not w["count"].exists() + source.registered, source.failed = True, False + assert read(body={**operation, "retry": retry})["job"]["status"] == "succeeded" + assert source.reads == 3 + assert w["count"].read_text().splitlines() == ["started"] + assert read()["pack"]["scope"]["goal_id"] == "research-owner" + for path, original in w["before"].items(): + assert path.read_bytes() == original + + +def test_source_analysis_default_off_and_non_analysis_writes_stay_disabled(workspace): + w = workspace + with source_analysis_workspace(w, "disabled", enabled=False) as s: + with pytest.raises(HTTPError) as unavailable: + s["request"]() + assert unavailable.value.code == 503 + with pytest.raises(HTTPError) as unavailable_citation: + s["request"](suffix="/citation") + assert unavailable_citation.value.code == 404 + with pytest.raises(HTTPError) as readonly: + s["request"](body=s["operation"]) + assert readonly.value.code == 405 + with source_analysis_workspace(w, "enabled") as s: + for method in ("PUT", "PATCH", "DELETE"): + with pytest.raises(HTTPError) as readonly: + s["request"](body=s["operation"], method=method) + assert readonly.value.code == 405 + with pytest.raises(HTTPError) as origin: + s["request"](body=s["operation"], headers={"Origin": "https://unrelated.example"}) + assert origin.value.code == 403 + data = json.loads(s["input_path"].read_text()) + data["scope"]["goal_id"] = "another-goal" + s["input_path"].write_text(json.dumps(data)) + registered = register_pack(runtime_root=s["server"].analysis_runtime_root, + owner_goal_id="research-owner", input_path=s["input_path"], source_root=w["sources"], title="Wrong Goal") + for body in (None, dict(goal_id="research-owner", pack_id=registered["pack_id"], analysis_key=registered["analysis_key"])): + with pytest.raises(HTTPError): + s["request"](body=body) + assert not w["count"].exists() diff --git a/tests/test_chat_lark_api_contract.py b/tests/test_chat_lark_api_contract.py index 20b6404fae..40b89598d4 100644 --- a/tests/test_chat_lark_api_contract.py +++ b/tests/test_chat_lark_api_contract.py @@ -1,6 +1,11 @@ from __future__ import annotations import json +import subprocess +import threading +from http.server import BaseHTTPRequestHandler +from urllib.error import HTTPError +from urllib.request import urlopen from http.server import ThreadingHTTPServer from pathlib import Path from types import SimpleNamespace @@ -26,6 +31,124 @@ from loopx.extensions.lark.goal_channel_targets import add_lark_goal_channel_target +def test_lark_catalog_and_bounded_health_use_real_http_without_provider_writes() -> ( + None +): + calls: list[tuple[list[str], float]] = [] + broken = False + slow = False + + def runner(args: list[str], _cwd: Any, timeout: float) -> dict[str, Any]: + calls.append((args, timeout)) + if args[-2:] == ["profile", "list"]: + return { + "returncode": 0, + "stdout": "{}" + if broken + else json.dumps( + [ + { + "name": "demo", + "appId": "cli_demo", + "brand": "feishu", + "active": True, + }, + { + "name": "other", + "appId": "cli_other", + "brand": "feishu", + "active": False, + }, + ] + ), + } + assert args[-4:] == ["auth", "status", "--verify", "--json"] + if slow: + raise subprocess.TimeoutExpired(args, timeout) + return { + "returncode": 0, + "stdout": json.dumps( + { + "appId": "cli_demo", + "identities": { + "bot": {"available": True, "verified": True, "appName": "Demo"} + }, + } + ), + } + + class Handler(LarkChatRequestMixin, BaseHTTPRequestHandler): + def _require_lark_cli(self): + return "lark-cli" + + def _lark_runner(self): + return runner + + def do_GET(self): + self._lark_apps() + + def log_message(self, *_args): + pass + + def _send_json(self, payload, *, status=200): + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(payload).encode()) + + def _send_error(self, message, *, status, error_code): + self._send_json( + {"ok": False, "error": message, "error_code": error_code}, status=status + ) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def read(query): + with urlopen( + f"http://127.0.0.1:{server.server_port}/api/chat/lark/apps{query}", + timeout=10, + ) as response: + return json.load(response) + + try: + catalog = read("?verify=false") + assert len(catalog["apps"]) == 2 and len(calls) == 1 + assert all(not row["ready"] for row in catalog["apps"]) + calls.clear() + health = read("?verify=true&app_ref=demo") + assert health["apps"][0]["ready"] is True + assert len(calls) == 2 and all(timeout == 5 for _, timeout in calls) + slow = True + assert ( + read("?verify=true&app_ref=demo")["apps"][0]["health_error_code"] + == "lark_app_unverified" + ) + for query in [ + "?verify=unknown", + "?verify=false&verify=true", + "?app_ref=removed", + "?app_ref=../outside", + ]: + with pytest.raises(HTTPError) as error: + read(query) + assert error.value.code == 400 + broken = True + with pytest.raises(HTTPError) as error: + read("?verify=false") + assert error.value.code == 502 + assert "private" not in error.value.read().decode() + broken = False + slow = False + assert len(read("?verify=false")["apps"]) == 2 + assert all("profile" in args or "status" in args for args, _ in calls) + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_lark_management_uses_dedicated_local_api_routes() -> None: assert CHAT_GOAL_CONTEXTS_PATH == "/api/chat/goals/contexts" assert CHAT_LARK_APPS_PATH == "/api/chat/lark/apps" diff --git a/tests/test_collaboration_goal_instance.py b/tests/test_collaboration_goal_instance.py index fa1c7e8210..8280c0b00c 100644 --- a/tests/test_collaboration_goal_instance.py +++ b/tests/test_collaboration_goal_instance.py @@ -374,14 +374,14 @@ def test_inbox_read_rejects_recreation_before_response_commit( replacement = tmp_path / "replacement" replacement.mkdir() (replacement / input_path.name).write_text("instance B", encoding="utf-8") - render_followthrough = links.receiver_followthrough + read_work = links.receiver_work_observations def recreate_before_input_read(root, registry_path, items): - rendered = render_followthrough(root, registry_path, items) + rendered = read_work(root, registry_path, items) _recreate(registry, repo=replacement) return rendered - monkeypatch.setattr(links, "receiver_followthrough", recreate_before_input_read) + monkeypatch.setattr(links, "receiver_work_observations", recreate_before_input_read) with pytest.raises(ValueError, match="historical_mutation_forbidden"): read_inbox( @@ -448,13 +448,22 @@ def test_inbox_read_does_not_commit_peer_return_before_response_succeeds( caller_goal_ref=goal_ref, ) + # A real incoming input requires Host enrichment before the peer read commits. + input_path = tmp_path / "input.txt" + input_path.write_text("Current instance input", encoding="utf-8") + incoming_brief = { + **_brief("Inspect the current input before acknowledging peer delivery"), + "inputs": [{"ref": input_path.name, "description": "Current source input"}], + } + request(tmp_path, registry, "delivery", "reviewer", "builder", "read-input", incoming_brief) + def interrupt_response(*_args): if interruption == "goal_recreation": _recreate(registry) return None raise RuntimeError("response enrichment failed") - monkeypatch.setattr(links, "receiver_followthrough", interrupt_response) + monkeypatch.setattr(links, "receiver_work_observations", interrupt_response) expected_error = ValueError if interruption == "goal_recreation" else RuntimeError expected_message = ( diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 6eb7030703..0f5e0fd65b 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -305,6 +305,123 @@ def unavailable(**kwargs): runtime.close() +@pytest.mark.parametrize("unavailable", ["bot_ready", "owner_ready"]) +def test_owner_event_survives_identity_outage_and_recovers_once(ordinary, unavailable): # noqa: F811 + store, runtime, provider, transport = connect(ordinary) + event = provider.event("notes-app", "identity_outage", "Explain this project") + try: + setattr(provider, unavailable, False) + assert transport.admit("notes-app", event)["status"] == "audience_rejected" + path = next(transport.root.glob("*.json")) + captured = json.loads(path.read_text()) + assert captured["status"] == "captured" and captured["event"] == event + assert transport.pending_delivery_paths() == [path] + assert store.list_sessions() == [] and transport.core.pending() == [] + assert provider.writes == [] and provider.reaction_creates == [] + assert transport.reconcile_request(path) == 0 + assert json.loads(path.read_text()) == captured + + # The normal scanner can recover the same receipt after a restart; + # no source-history replay or second event consumer is involved. + restored = LarkPrivateConversations(controller=runtime, runtime_root=store.root.parent, + runner=provider, cli_bin="lark-cli") + setattr(provider, unavailable, True) + restored.reconcile_request(path) + row = restored.core.pending()[0] + result = runtime.wait_for_turn(session_id=row["session_id"], turn_id=row["turn_id"], timeout_sec=10) + assert result["status"] == "completed" + restored.reconcile() + delivered = json.loads(path.read_text()) + assert delivered["status"] == "delivered" and delivered["event"] == event + assert delivered["deliveries"]["terminal"]["verified"] is True + assert len(store.list_sessions()) == 1 + assert [m["text"] for m in store.messages(row["session_id"]) if m["role"] == "user"] == [event["content"]] + provider_messages = json.loads(json.dumps(provider.messages)) + provider_writes = list(provider.writes) + restored.admit("notes-app", event) + restored.reconcile() + assert provider.messages == provider_messages and provider.writes == provider_writes + packets = [json.loads(line) for line in ordinary[4].read_text().splitlines()] + assert sum(packet["method"] == "turn/start" for packet in packets) == 1 + finally: + runtime.close() + + +@pytest.mark.parametrize("change", ["revoked", "source_sender_changed", "owner_changed"]) +def test_unverified_owner_receipt_does_not_grant_later_execution(ordinary, monkeypatch, change): # noqa: F811 + store, runtime, provider, transport = connect(ordinary) + event = provider.event("notes-app", "unverified_boundary", "Explain this project") + try: + provider.owner_ready = False + assert transport.admit("notes-app", event)["status"] == "audience_rejected" + path = next(transport.root.glob("*.json")) + captured = json.loads(path.read_text()) + provider.owner_ready = True + if change == "revoked": + transport.bindings.disconnect(captured["binding_id"], + expected_revision=transport.bindings.read()["revision"]) + elif change == "source_sender_changed": + provider.messages[event["message_id"]]["sender"]["id"] = "ou_other" + else: + observe = transport.bindings.observe + monkeypatch.setattr(transport.bindings, "observe", lambda profile: { + **observe(profile), "operator_ref": "f" * 24}) + assert transport.reconcile_request(path) == 0 + assert store.list_sessions() == [] and transport.core.pending() == [] + assert provider.writes == [] and provider.reaction_creates == [] + assert json.loads(path.read_text())["status"] == "captured" + finally: + runtime.close() + + +@pytest.mark.parametrize("group", [False, True]) +def test_identity_outage_does_not_precapture_groups_or_other_owners(ordinary, group): # noqa: F811 + store, runtime, provider, transport = (connect_group if group else connect)(ordinary) + event = provider.topic("unverified_group", "Explain this project") if group else provider.event( + "notes-app", "unverified_other_owner", "Explain this project") + if not group: + event["sender_id"] = "ou_other" + try: + provider.bot_ready = False + assert transport.admit("notes-app", event)["status"] == "audience_rejected" + assert list(transport.root.glob("*.json")) == [] + assert store.list_sessions() == [] and transport.core.pending() == [] + assert provider.writes == [] and provider.reaction_creates == [] + finally: + runtime.close() + + +def test_rejected_source_returns_notice_without_creating_core_execution(ordinary, monkeypatch): # noqa: F811 + store, runtime, provider, transport = connect(ordinary) + event = provider.event("notes-app", "rejected_before_core", "Explain this project") + try: + admit = transport.core.admit + def reject(**_kwargs): + raise ValueError("identity changed before Core admission") + + monkeypatch.setattr(transport.core, "admit", reject) + assert transport.admit("notes-app", event)["status"] == "command_rejected" + path = next(transport.root.glob("*.json")) + captured = json.loads(path.read_text()) + native_path = transport.core.root / f"{captured['request_ref']}.json" + assert not native_path.exists() + assert transport.reconcile_request(path) == 1 + delivered = json.loads(path.read_text()) + assert delivered["status"] == "delivered" + assert delivered["deliveries"]["terminal"]["verified"] is True + assert len(provider.writes) == 1 and not native_path.exists() + assert store.list_sessions() == [] + with pytest.raises(ValueError, match="request journal unavailable"): + transport.core.record_delivery(captured["request_ref"], session_id=None, turn_id=None) + assert not native_path.exists() + monkeypatch.setattr(transport.core, "admit", admit) + assert transport.admit("notes-app", event)["status"] == "command_rejected" + assert not native_path.exists() and store.list_sessions() == [] + assert len(provider.writes) == 1 + finally: + runtime.close() + + @pytest.mark.parametrize("group", [False, True]) def test_completion_during_processing_receipt_returns_final_without_stale_draft(ordinary, monkeypatch, tmp_path, group): # noqa: F811 store, runtime, provider, transport = (connect_group if group else connect)(ordinary) diff --git a/tests/test_native_steward_private.py b/tests/test_native_steward_private.py index c4697fa787..ef12e320df 100644 --- a/tests/test_native_steward_private.py +++ b/tests/test_native_steward_private.py @@ -114,6 +114,26 @@ def test_personal_steward_uses_registered_portfolio_and_configured_inbox_root(st assert not (workspace / "ACTIVE_GOAL_STATE.md").exists() +def test_legacy_delivery_only_receipt_does_not_block_steward_conversation(steward): + store, runtime, provider, transport, _, capture, _ = steward + orphan = transport.core.root / ("f" * 24 + ".json") + orphan.parent.mkdir(parents=True, exist_ok=True) + orphan.write_text(json.dumps({"delivery_verified": True})) + before = orphan.read_bytes() + event = provider.event("steward-app", "after_legacy_receipt", "Check the authorized portfolio") + assert transport.admit("steward-app", event)["status"] == "durably_accepted" + row = next(r for r in transport.core.pending() if r.get("request_ref") == identity_ref( + transport.bindings.read()["bindings"][1]["provider_ref"], event["message_id"])) + assert finish(runtime, row)["status"] == "completed" + session = store.load_session(row["session_id"]) + assert transport.core.commission_evidence(session) == [] + selection = provider.event("notes-app", "select_after_legacy_receipt", "/project") + assert transport.admit("notes-app", selection)["status"] == "command_recorded" + assert orphan.read_bytes() == before + packets = [json.loads(line) for line in capture.read_text().splitlines()] + assert sum(p["method"] == "turn/start" for p in packets) == 1 + + def test_verified_empty_steward_is_not_missing_authorization_and_does_not_inherit(steward): store, runtime, provider, transport, binding, _, _ = steward transport.admit("steward-app", provider.event("steward-app", "empty", "What new work do you manage?"))