diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 30e607b911..8e6ef9e604 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1508,12 +1508,12 @@ export async function setupGoalChannel(options: { execute: boolean; goalId: stri ); } -export async function configureGoalChannelAutoNotify(options: { autoNotify: boolean; goalId: string }) { +export async function configureGoalChannelAutoNotify(options: { autoNotify: boolean; goalId: string; kind?: "human_gate" | "blocked_notice" }) { return goalChannelOperationSchema.parse( await requestJson("/api/chat/goal-channel/configure", { method: "POST", body: JSON.stringify({ - auto_notify_human_gates: options.autoNotify, + ...(options.kind === "blocked_notice" ? { auto_notify_blocked_notices: options.autoNotify } : { auto_notify_human_gates: options.autoNotify }), goal_id: options.goalId, }), }), diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index c012a416e6..4dbc91fb69 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -232,6 +232,12 @@ export const goalChannelNotificationRowSchema = z.object({ configured: z.boolean().optional().default(false), enabled: z.boolean().optional().default(false), human_gate_auto_notify_enabled: z.boolean().optional().default(false), + blocked_notice_auto_notify_enabled: z.boolean().optional().default(false), + blocked_notice_delivery: z.object({ + delivered_count: z.number(), + unverified_count: z.number(), + resolved_count: z.number(), + }).optional(), target_ref: z.string().optional().nullable(), receipt_count: z.number().optional().default(0), last_notified_at: z.string().optional().nullable(), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx index d4e48be011..3aec0a83c7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx @@ -265,12 +265,28 @@ function CapabilityCatalog({ callbacks, catalog, goalId, notification, onApplied {t("capabilities.larkInboxNotificationSetting")}

{t("capabilities.larkInboxNotificationDescription")}

- +
+ + + {notification?.blockedNoticeDelivery ?

+ {t("notifications.blockedDelivery", { + delivered: notification.blockedNoticeDelivery.deliveredCount, + unverified: notification.blockedNoticeDelivery.unverifiedCount, + resolved: notification.blockedNoticeDelivery.resolvedCount, + })} +

: null} +
) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index ef2cb8c549..37498061db 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -674,6 +674,8 @@ const en = { "lark.error.cliStart": "lark-cli failed to start. Check the installation and restart LoopX.", "lark.error.disconnect": "Disconnect failed", "notifications.autoNotify": "Send automatically when your confirmation is required", + "notifications.blockedAutoNotify": "Send blocked task notices to this Goal Channel", + "notifications.blockedDelivery": "Blocked notices: {delivered} verified, {unverified} unverified, {resolved} resolved", "notifications.bind": "Bind notification group", "notifications.bindConfirm": "Bind “{goal}” to “{target}”. LoopX will verify that the bot is in the group and send a confirmation message.", "notifications.bindFailed": "Binding failed", @@ -1010,8 +1012,8 @@ const en = { "capabilities.goalScope": "Goal", "capabilities.goalValue": "Current Goal value", "capabilities.loadFailed": "Could not load Goal capabilities", - "capabilities.larkInboxNotificationDescription": "This switch controls automatic group messages for human gates. Incoming Lark events remain available when it is off.", - "capabilities.larkInboxNotificationSetting": "Human-gate group notifications", + "capabilities.larkInboxNotificationDescription": "Choose which Goal Channel notices to send. Incoming Lark events remain available when both are off.", + "capabilities.larkInboxNotificationSetting": "Goal Channel notifications", "capabilities.loading": "Loading Goal capabilities…", "capabilities.machineOnly": "This capability is configured only at machine scope.", "capabilities.machineValue": "Device defaults", @@ -1922,6 +1924,8 @@ const zhCN: Record = { "lark.error.cliStart": "lark-cli 启动失败。请检查安装状态,然后重新启动 LoopX。", "lark.error.disconnect": "解绑失败", "notifications.autoNotify": "需要你确认时自动推送到群里", + "notifications.blockedAutoNotify": "任务受阻时推送到此目标群", + "notifications.blockedDelivery": "受阻通知:已核验 {delivered} 条,未核验 {unverified} 条,已解除 {resolved} 条", "notifications.bind": "绑定到通知群", "notifications.bindConfirm": "将把「{goal}」绑定到通知群「{target}」,绑定时会验证机器人在群内并发送一条确认消息。", "notifications.bindFailed": "绑定失败", @@ -2258,8 +2262,8 @@ const zhCN: Record = { "capabilities.goalScope": "Goal", "capabilities.goalValue": "当前 Goal 值", "capabilities.loadFailed": "无法加载 Goal 能力", - "capabilities.larkInboxNotificationDescription": "此开关只控制遇到人工 Gate 时是否自动发群消息;关闭后仍会保留飞书事件收件箱能力。", - "capabilities.larkInboxNotificationSetting": "Gate 群通知", + "capabilities.larkInboxNotificationDescription": "分别选择要自动发送的目标群通知;两个开关均关闭时仍可接收飞书事件。", + "capabilities.larkInboxNotificationSetting": "目标群通知", "capabilities.loading": "正在加载 Goal 能力…", "capabilities.machineOnly": "此能力只能在机器作用域配置。", "capabilities.machineValue": "设备默认值", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/notification-settings-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/notification-settings-panel.tsx index 924d904aee..78ba1fa663 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/notification-settings-panel.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/notification-settings-panel.tsx @@ -21,11 +21,13 @@ export function GoalAutoNotifyToggle({ goalId, notification, onChanged, + kind = "human_gate", }: { callbacks: PersonalWorkspaceCallbacks; goalId: string; notification?: WorkspaceGoalNotification; onChanged: () => void; + kind?: "human_gate" | "blocked_notice"; }) { const { t } = useWorkspaceI18n(); const [busy, setBusy] = useState(false); @@ -36,7 +38,7 @@ export function GoalAutoNotifyToggle({ setBusy(true); setError(null); try { - const result = await callbacks.onToggleGoalAutoNotify({ autoNotify, goalId }); + const result = await callbacks.onToggleGoalAutoNotify({ autoNotify, goalId, kind }); if (!result.ok) { setError(result.public_summary ?? result.blocker ?? t("notifications.setupFailed")); return; @@ -53,12 +55,12 @@ export function GoalAutoNotifyToggle({ <> {error ?

{error}

: null} @@ -130,6 +132,7 @@ function GoalNotificationRow({ notification={notification} onChanged={onChanged} /> + ) : targets.length === 0 ? (

diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 11b0adf622..69b3922f7e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -355,6 +355,8 @@ export type WorkspaceGoalNotification = { configured: boolean; enabled: boolean; humanGateAutoNotifyEnabled: boolean; + blockedNoticeAutoNotifyEnabled?: boolean; + blockedNoticeDelivery?: { deliveredCount: number; unverifiedCount: number; resolvedCount: number }; lastNotifiedAt?: string | null; receiptCount: number; targetRef?: string | null; @@ -493,7 +495,7 @@ export type PersonalWorkspaceCallbacks = { onOpenNotificationSettings?: (goalId?: string) => void; onFetchNotificationTargets?: () => Promise>; onSetupGoalChannel?: (options: { execute: boolean; goalId: string; target: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>; - onToggleGoalAutoNotify?: (options: { autoNotify: boolean; goalId: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>; + onToggleGoalAutoNotify?: (options: { autoNotify: boolean; goalId: string; kind?: "human_gate" | "blocked_notice" }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>; }; // What one send hands back for review: at most one decision the owner reviews 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 0a09da6b97..5127981732 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -904,6 +904,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-capability-editor-status.is-read-only { background: #fff5e8; color: #9a5a10; } .personal-capability-linked-setting { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; margin: 0 24px 20px; padding: 14px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); } .personal-capability-linked-setting > div { display: grid; gap: 4px; } +.personal-capability-linked-controls { min-width: 0; justify-items: start; } .personal-capability-linked-setting strong { font-size: 12px; } .personal-capability-linked-setting .personal-notification-toggle { justify-self: end; min-height: 44px; } .personal-capability-linked-setting .personal-notification-error { grid-column: 1 / -1; } diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index bd20b1895f..9e13ae25fb 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1195,6 +1195,12 @@ function buildPersonalHomeModel( configured: row.configured, enabled: row.enabled, humanGateAutoNotifyEnabled: row.human_gate_auto_notify_enabled, + blockedNoticeAutoNotifyEnabled: row.blocked_notice_auto_notify_enabled, + blockedNoticeDelivery: row.blocked_notice_delivery ? { + deliveredCount: row.blocked_notice_delivery.delivered_count, + unverifiedCount: row.blocked_notice_delivery.unverified_count, + resolvedCount: row.blocked_notice_delivery.resolved_count, + } : undefined, lastNotifiedAt: row.last_notified_at ?? null, receiptCount: row.receipt_count, targetRef: row.target_ref ?? null, diff --git a/docs/architecture/rfcs/goal-channel-collaboration-v0.md b/docs/architecture/rfcs/goal-channel-collaboration-v0.md index ecc6b8bf37..5964cd6da2 100644 --- a/docs/architecture/rfcs/goal-channel-collaboration-v0.md +++ b/docs/architecture/rfcs/goal-channel-collaboration-v0.md @@ -301,6 +301,37 @@ delivery persistently with: loopx goal-channel configure --goal-id --no-auto-notify-human-gates --execute ``` +Blocked Todo notices use a separate, default-off opt-in. They carry the task, +cause, impact, responsible party, recovery condition, and next action. A blocked +primary Todo remains visible when safe fallback work continues. Enable or +remove this delivery through the same private Goal Channel binding: + +```bash +loopx goal-channel configure --goal-id --auto-notify-blocked-notices +loopx goal-channel configure --goal-id --auto-notify-blocked-notices --execute +loopx goal-channel configure --goal-id --no-auto-notify-blocked-notices --execute +``` + +A material refresh sends only through the authorized Lark sink. Deduplication +binds blocker identity, revision, destination chat, and delivery generation. +An explicitly observed open Todo retires the old blocker as resumed; terminal +or superseding canonical facts retire it separately. Missing or paginated rows +never prove recovery. Repeated recovery works even within one timestamp. + +Each refresh attempts at most eight pending effects, prioritizing unattempted +notices before retries. Verified receipts do not consume that budget; the full +frontier and deferred pending receipts remain available for later refreshes. +A batch with pending work is not reported as fully verified. Switching channels +requires independent send/readback for the new destination and retains the old +history. Public status counts refer to the current target and exclude retired +receipts from active delivery totals. + +The private binding records pending, sent-but-unverified, delivered, resumed, +resolved, and superseded receipts. Provider failure or a missing sink leaves +notices pending without stopping safe fallback. `status.json` exposes only +public-safe counts and the two opt-ins. Chat replies do not approve or recover +Todos; recovery remains a canonical Todo fact. + The opt-in is stored only in the project-local private Goal Channel binding. It does not grant repository or LoopX transition authority. Chat replies can provide context, but a gate changes only after LoopX validates and records the diff --git a/docs/architecture/rfcs/goal-channel-collaboration-v0.zh-CN.md b/docs/architecture/rfcs/goal-channel-collaboration-v0.zh-CN.md index e431d77330..d2a77b8f5f 100644 --- a/docs/architecture/rfcs/goal-channel-collaboration-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-channel-collaboration-v0.zh-CN.md @@ -272,6 +272,31 @@ loopx goal-channel configure --goal-id --auto-notify-human-gates --exe loopx goal-channel configure --goal-id --no-auto-notify-human-gates --execute ``` +受阻 Todo 通知有独立的 opt-in,默认关闭。通知包含任务、原因、影响、解除责任人、 +恢复条件和下一步;安全回退继续时,主要受阻任务仍会显式呈现。通过同一个私有 +Goal Channel binding 开启或关闭: + +```bash +loopx goal-channel configure --goal-id --auto-notify-blocked-notices +loopx goal-channel configure --goal-id --auto-notify-blocked-notices --execute +loopx goal-channel configure --goal-id --no-auto-notify-blocked-notices --execute +``` + +实际刷新只向已授权的 Lark sink 发送。去重同时绑定阻塞身份、修订、目标群和投递世代。 +明确观察到 canonical Todo 恢复为 open 时,旧阻塞回执标记为 resumed;终结及被取代 +事实分别退休旧回执。缺失或分页省略的任务不能证明恢复;同一时间戳内多次恢复也会 +产生不同投递世代。 + +每次刷新最多尝试八条待发送效果,未尝试的通知优先于重试,已核验回执不占用额度。 +完整候选与延后的 pending 回执保留到后续刷新;仍有待处理通知时不能宣称全部核验。 +切换目标群必须独立发送并回读,旧群历史继续保留。公开计数只针对当前目标群,已退休 +回执不计入当前已送达数量。 + +私有 binding 保存待发送、已发送但未核验、已核验、已恢复、已解除及被取代状态。 +发送失败或没有可用 sink 时保持待发送状态,安全回退不受阻。`status.json` 只公开计数 +与两个 opt-in,不公开私有消息身份。群聊回复不构成 Todo 批准或解除;恢复须由 +canonical Todo 状态确认。 + 该 opt-in 只保存在项目本地私有的 Goal Channel binding 中,不授予仓库或 LoopX 状态迁移权限。群聊回复可以补充 context,但只有经过 LoopX 校验并记录的 decision 才能改变 gate 状态。 diff --git a/examples/loopx-chat-server-smoke.py b/examples/loopx-chat-server-smoke.py index 6f0048843c..7fdd559c5d 100644 --- a/examples/loopx-chat-server-smoke.py +++ b/examples/loopx-chat-server-smoke.py @@ -390,6 +390,23 @@ def main() -> None: assert code == 400, configure_invalid assert configure_invalid["error_code"] == "invalid_goal_channel_configure", configure_invalid + code, blocked_notice_missing = request_json( + f"{base_url}/api/chat/goal-channel/configure", + method="POST", + body={"goal_id": GOAL_ID, "auto_notify_blocked_notices": False}, + ) + assert code == 400, blocked_notice_missing + assert blocked_notice_missing["blocker"] == "channel_binding_missing", blocked_notice_missing + + code, configure_conflict = request_json( + f"{base_url}/api/chat/goal-channel/configure", + method="POST", + body={"goal_id": GOAL_ID, "auto_notify_human_gates": True, + "auto_notify_blocked_notices": True}, + ) + assert code == 400, configure_conflict + assert configure_conflict["error_code"] == "invalid_goal_channel_configure", configure_conflict + registry_before_subagent_preview = registry.read_text(encoding="utf-8") code, invalid_domain = request_json( f"{base_url}/api/chat/goal-subagents/dry-run", diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 4f683a40e4..96603c69f1 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -31,6 +31,7 @@ import { import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs"; import { capabilityScopeScenario } from "./personal-workspace-browser/capability-scope.mjs"; import { performanceDiagnosisScenario } from "./personal-workspace-browser/performance-diagnosis.mjs"; +import { blockedNoticeSettingsScenario } from "./personal-workspace-browser/blocked-notice-settings.mjs"; import { automationCadenceScenario } from "./personal-workspace-browser/automation-cadence.mjs"; import { turnStepsScenario } from "./personal-workspace-browser/turn-steps.mjs"; import { monitorReadbackScenario } from "./personal-workspace-browser/monitor-readback.mjs"; @@ -68,6 +69,7 @@ scenarioCatalog.push(monitorReadbackScenario); scenarioCatalog.push(turnStepsScenario); scenarioCatalog.push(goalWorkMapScenario); scenarioCatalog.push(performanceDiagnosisScenario); +scenarioCatalog.push(blockedNoticeSettingsScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/blocked-notice-settings.mjs b/examples/personal-workspace-browser/blocked-notice-settings.mjs new file mode 100644 index 0000000000..0aaa0002f9 --- /dev/null +++ b/examples/personal-workspace-browser/blocked-notice-settings.mjs @@ -0,0 +1,95 @@ +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +export const blockedNoticeSettingsScenario = { + id: "blocked-notice-settings", + async run({ browser, collectCoverage, url }) { + const notificationProjection = { + schema_version: "loopx_goal_channel_notification_projection_v0", + goals: [{ + goal_id: "product-release", configured: true, enabled: true, + human_gate_auto_notify_enabled: false, blocked_notice_auto_notify_enabled: false, + receipt_count: 1, + blocked_notice_delivery: { delivered_count: 1, unverified_count: 0, resolved_count: 0 }, + }], + }; + const requests = []; + const context = await openWorkspacePage(browser, url, { + collectCoverage, + apiOptions: { notificationProjection }, + beforeGoto: async (_api, page) => { + await page.route("**/api/chat/goal-channel/configure", async (route) => { + const body = route.request().postDataJSON(); + requests.push(body); + if (requests.length === 1) { + await route.fulfill({ contentType: "application/json", json: { + ok: false, status: "failed", public_summary: "Fixture configuration write failed", readback_verified: false, + }, status: 200 }); + return; + } + notificationProjection.goals[0].blocked_notice_auto_notify_enabled = body.auto_notify_blocked_notices === true; + await route.fulfill({ contentType: "application/json", json: { + ok: true, status: "configured", public_summary: "updated", readback_verified: true, + }, status: 200 }); + }); + }, + }); + const { page } = context; + try { + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.getByRole("button", { name: "能力中心", exact: true }).click(); + await page.getByRole("radio", { name: "单个 Goal", exact: true }).check(); + await page.getByRole("combobox", { name: "目标 Goal", exact: true }).selectOption("product-release"); + await page.getByRole("navigation", { name: "Goal 能力目录" }) + .getByRole("button", { name: /飞书事件收件箱/ }).click(); + const toggle = page.getByLabel("任务受阻时推送到此目标群"); + await toggle.waitFor(); + await page.getByText("受阻通知:已核验 1 条,未核验 0 条,已解除 0 条").waitFor(); + await page.screenshot({ path: resolve(outputDir, "blocked-notice-settings.png"), animations: "disabled" }); + if (await toggle.isChecked()) throw new Error("Blocked notifications must start disabled"); + await toggle.click(); + await page.getByRole("alert").filter({ hasText: "Fixture configuration write failed" }).waitFor(); + if (await toggle.isChecked()) throw new Error("A failed write enabled blocked notifications"); + await toggle.click(); + if (requests.length !== 2 || requests[1].goal_id !== "product-release" + || requests[1].auto_notify_blocked_notices !== true + || "auto_notify_human_gates" in requests[1]) { + throw new Error(`Blocked notice toggle sent the wrong request: ${JSON.stringify(requests)}`); + } + await page.waitForFunction(() => { + const input = [...document.querySelectorAll("input[type=checkbox]")] + .find((item) => item.closest("label")?.textContent?.includes("任务受阻时推送到此目标群")); + return input?.checked === true; + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: resolve(outputDir, "blocked-notice-settings-mobile.png"), animations: "disabled" }); + if (await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1)) { + throw new Error("Blocked notice settings overflow the narrow viewport"); + } + await page.reload({ waitUntil: "networkidle" }); + if (!await toggle.isVisible()) { + await page.getByRole("button", { name: "打开 Goal 导航", exact: true }).click(); + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.getByRole("button", { name: "能力中心", exact: true }).click(); + await page.getByRole("radio", { name: "单个 Goal", exact: true }).check(); + await page.getByRole("combobox", { name: "目标 Goal", exact: true }).selectOption("product-release"); + await page.getByRole("navigation", { name: "Goal 能力目录" }) + .getByRole("button", { name: /飞书事件收件箱/ }).click(); + } + await toggle.waitFor(); + if (!await toggle.isChecked()) throw new Error("Reload lost the verified blocked-notice setting"); + notificationProjection.goals[0].blocked_notice_delivery = { delivered_count: 8, unverified_count: 1, resolved_count: 1 }; + await toggle.focus(); + await page.keyboard.press("Space"); + await page.getByText("受阻通知:已核验 8 条,未核验 1 条,已解除 1 条").waitFor(); + if (await toggle.isChecked() || requests.at(-1).auto_notify_blocked_notices !== false + || notificationProjection.goals[0].human_gate_auto_notify_enabled !== false) { + throw new Error("Disabling blocked notices changed the independent human-gate setting"); + } + await page.screenshot({ path: resolve(outputDir, "blocked-notice-settings-mobile-readback.png"), animations: "disabled" }); + if (context.errors.length) throw new Error(context.errors.join(" | ")); + return { coverageEntries: await context.close(), note: "Goal Channel blocked notice default-off, failed-write correction, reload, keyboard disable and pending/retired readback verified." }; + } catch (error) { await context.close(); throw error; } + }, +}; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 8bcaa70fdf..9c2393e2db 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -301,7 +301,7 @@ server.serve_forever() server.stderr.on("data", (chunk) => { diagnostic = (diagnostic + chunk).slice(-8000); }); server.stdout.on("data", (chunk) => { output += chunk; - if (output.includes("loopx-packaged-smoke-ready\n")) { + if (/loopx-packaged-smoke-ready\r?\n/u.test(output)) { clearTimeout(timer); resolveReady(); } @@ -391,7 +391,7 @@ function filterStatusFixtureToScope(fixture, matchesScope) { } } -export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, progressiveWorkspace = false, runtimeAgents = null } = {}) { +export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, notificationProjection = null, progressiveWorkspace = false, runtimeAgents = null } = {}) { let turnCounter = 0; const runtime = page.__loopxRuntime ??= { actionProposals: new Map(), goalSubagentConfigurations: new Map(), larkConnections: [], messages: new Map(), sessions: new Map(), turnMessages: new Map() }; const actionProposals = runtime.actionProposals; @@ -506,6 +506,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true } if (progressiveWorkspace && requestedGoalId) state.goalStatusRequests.push(requestedGoalId); const fixture = structuredClone(require(resolve(repoRoot, "examples/status.example.json"))); + if (notificationProjection) fixture.goal_channel_notification_projection = structuredClone(notificationProjection); const defaultSubagentConfiguration = { mode: "default", spawn_allowed: false, max_children: 0, allowed_domains: [] }; const projectedSubagentConfiguration = (goalId, fallback) => state.freezeGoalSubagentStatusProjection ? fallback ?? defaultSubagentConfiguration diff --git a/loopx/chat_server.py b/loopx/chat_server.py index b0860aca6b..29e57f66cb 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1054,27 +1054,50 @@ def _goal_channel_setup(self) -> None: def _goal_channel_configure(self) -> None: try: body = self._read_json() - if set(body) - {"goal_id", "auto_notify_human_gates"}: + if set(body) - { + "goal_id", + "auto_notify_human_gates", + "auto_notify_blocked_notices", + }: raise ValueError("unknown Goal Channel configure field") goal_id = _compact_text(body.get("goal_id"), limit=160) auto_notify = body.get("auto_notify_human_gates") - if not goal_id or not isinstance(auto_notify, bool): - raise ValueError("goal_id and auto_notify_human_gates are required") + blocked_notify = body.get("auto_notify_blocked_notices") + if ( + not goal_id + or (isinstance(auto_notify, bool) == isinstance(blocked_notify, bool)) + or (auto_notify is not None and not isinstance(auto_notify, bool)) + or (blocked_notify is not None and not isinstance(blocked_notify, bool)) + ): + raise ValueError( + "goal_id and exactly one boolean notification setting are required" + ) source_registry, binding_path = self._goal_channel_context(goal_id) - if auto_notify: + if auto_notify is True or blocked_notify is True: extension_blocker = self._goal_channel_extension_ready() if extension_blocker is not None: - self._send_error(extension_blocker, status=400, error_code="extension_unavailable") + self._send_error( + extension_blocker, + status=400, + error_code="extension_unavailable", + ) return packet = configure_lark_goal_channel_automation( registry=source_registry, goal_id=goal_id, binding_path=binding_path, - human_gate_auto_notify=auto_notify, + human_gate_auto_notify=auto_notify + if isinstance(auto_notify, bool) + else None, + blocked_notice_auto_notify=blocked_notify + if isinstance(blocked_notify, bool) + else None, execute=True, ) except ValueError as exc: - self._send_error(str(exc), status=400, error_code="invalid_goal_channel_configure") + self._send_error( + str(exc), status=400, error_code="invalid_goal_channel_configure" + ) return except Exception: self._send_error( @@ -1085,7 +1108,9 @@ def _goal_channel_configure(self) -> None: return if not packet.get("ok"): packet["error"] = _compact_text( - packet.get("public_summary") or packet.get("blocker") or "Goal Channel configure failed" + packet.get("public_summary") + or packet.get("blocker") + or "Goal Channel configure failed" ) self._send_json(packet, status=200 if packet.get("ok") else 400) diff --git a/loopx/cli_commands/goal_channel.py b/loopx/cli_commands/goal_channel.py index 0d0215acb1..66f246b385 100644 --- a/loopx/cli_commands/goal_channel.py +++ b/loopx/cli_commands/goal_channel.py @@ -24,7 +24,9 @@ sync_lark_goal_channel, ) from ..extensions.lark.goal_channel_contracts import ( - binding_for_goal, notification_request_snapshot, operation_packet, + binding_for_goal, + notification_request_snapshot, + operation_packet, ) from ..extensions.lark.goal_topic_batch import upgrade_lark_goal_topics from ..extensions.runtime import ( @@ -172,6 +174,21 @@ def register_goal_channel_commands( action="store_false", help="Disable automatic human gate notifications.", ) + automation.add_argument( + "--auto-notify-blocked-notices", + dest="auto_notify_blocked_notices", + action="store_true", + help="Send new or materially changed blocked Todo notices to the Goal Channel.", + ) + automation.add_argument( + "--no-auto-notify-blocked-notices", + dest="auto_notify_blocked_notices", + action="store_false", + help="Disable automatic blocked Todo notices.", + ) + configure.set_defaults( + auto_notify_human_gates=None, auto_notify_blocked_notices=None + ) configure.add_argument("--execute", action="store_true") doctor = sub.add_parser( @@ -486,7 +503,10 @@ def handle_goal_channel_command( assert payload is not None print_payload(payload, output_format(args), render_goal_channel_markdown) return 0 if payload.get("ok") else 1 - if command == "configure" and bool(args.auto_notify_human_gates): + if command == "configure" and ( + args.auto_notify_human_gates is True + or getattr(args, "auto_notify_blocked_notices", None) is True + ): assert goal_id is not None _, source_registry_path, binding_path, _ = _source_context( registry=registry, @@ -500,7 +520,10 @@ def handle_goal_channel_command( default_binding_path = None if ( command == "configure" - and bool(args.auto_notify_human_gates) + and ( + args.auto_notify_human_gates is True + or getattr(args, "auto_notify_blocked_notices", None) is True + ) and binding_path is not None and default_binding_path is not None and binding_path.resolve() != default_binding_path.resolve() @@ -511,13 +534,16 @@ def handle_goal_channel_command( execute=execute, blocker="noncanonical_binding_path", summary=( - "automatic human gate delivery requires the project-local " + "automatic Goal Channel delivery requires the project-local " "default Goal Channel binding" ), ) print_payload(payload, output_format(args), render_goal_channel_markdown) return 1 - if command == "configure" and not bool(args.auto_notify_human_gates): + if command == "configure" and ( + args.auto_notify_human_gates is False + or getattr(args, "auto_notify_blocked_notices", None) is False + ): assert goal_id is not None source_registry, _, binding_path, _ = _source_context( registry=registry, @@ -530,7 +556,10 @@ def handle_goal_channel_command( registry=source_registry, goal_id=goal_id, binding_path=binding_path, - human_gate_auto_notify=False, + human_gate_auto_notify=args.auto_notify_human_gates, + blocked_notice_auto_notify=getattr( + args, "auto_notify_blocked_notices", None + ), execute=execute, ) except Exception: @@ -686,7 +715,10 @@ def handle_goal_channel_command( registry=source_registry, goal_id=goal_id, binding_path=binding_path, - human_gate_auto_notify=bool(args.auto_notify_human_gates), + human_gate_auto_notify=args.auto_notify_human_gates, + blocked_notice_auto_notify=getattr( + args, "auto_notify_blocked_notices", None + ), execute=execute, ) elif command == "doctor": diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index b316dc034e..1e702c233c 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -41,6 +41,8 @@ from ..extensions.lark.goal_channel_lifecycle import ( goal_channel_gate_sync_failure, sync_human_gate_after_refresh, + blocked_notice_sync_failure, + sync_blocked_notice_after_refresh, ) from ..history import load_registry from ..paths import resolve_runtime_root @@ -738,6 +740,24 @@ def handle_refresh_state_command( goal_id=args.goal_id, exception=error, ) + try: + blocked_sync = sync_blocked_notice_after_refresh( + registry_path=registry_path, + runtime_root_override=args.runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + external_sink_delivery_authorized=payload[ + "external_sink_delivery_authorized" + ] + is True, + ) + except Exception as error: + blocked_sync = blocked_notice_sync_failure( + registry_path=registry_path, + goal_id=args.goal_id, + exception=error, + ) + payload["goal_channel_blocked_notice_sync"] = blocked_sync payload["goal_channel_gate_sync"] = gate_sync apply_external_sink_postcondition( payload, diff --git a/loopx/control_plane/quota/blocked_transition_notice.py b/loopx/control_plane/quota/blocked_transition_notice.py index 8e8b44c1b5..dcf0b53066 100644 --- a/loopx/control_plane/quota/blocked_transition_notice.py +++ b/loopx/control_plane/quota/blocked_transition_notice.py @@ -14,13 +14,11 @@ Each notice also carries a ``blocker_revision`` digest over the cause, evidence, recovery condition, responsible party and supersession marker. The digest is the -dedup key a later ledger needs — unchanged means "already told", changed means -"tell again" — but this module does not decide emission: that decision, the -persisted delivery/readback state and the reconciliation of resolved and -superseded blockers arrive with the successor that owns a real caller. Until -then every notice reports ``delivery.state == "pending"``: no delivery surface -has been authorized, so nothing has reached anybody, and a NOTIFY intent must -not be reported as a delivery. +dedup key used by an authorized delivery adapter — unchanged means "already +told", changed means "tell again". This transport-neutral builder does not +decide emission: it reports ``delivery.state == "pending"`` until a delivery +adapter records a separate receipt. The Lark Goal Channel adapter owns +send/readback state in its private binding; a NOTIFY intent is not delivery. """ from __future__ import annotations @@ -50,9 +48,8 @@ BLOCKED_TRANSITION_NOTICE_SCHEMA_VERSION = "blocked_transition_notice_v0" BLOCKED_TRANSITION_NOTICE_KIND = "blocked_transition_notice" -# The only delivery state this slice can honestly report: no surface has been -# authorized yet. "delivered" and "readback_verified" arrive with the successor -# that records an actual handover. +# The transport-neutral notice remains pending until an authorized adapter +# records delivery/readback in its own receipt store. NOTICE_DELIVERY_PENDING = "pending" RESPONSIBLE_AGENT = "agent" @@ -168,7 +165,7 @@ def build_blocked_transition_notice( ``blocker_revision`` digest so a later ledger can dedup "same blocker, same cause" from "same blocker, materially changed cause", but it does not itself decide whether to emit: emission, delivery recording and reconciliation need -an authorized delivery surface and belong to the successor slice. + an authorized delivery surface and belong to the delivery adapter. The module also owns how a notice is rendered for the owner (:func:`blocked_priority_fallback_owner_reason`), so the projection that shows diff --git a/loopx/extensions/lark/goal_channel_blocked_notice.py b/loopx/extensions/lark/goal_channel_blocked_notice.py new file mode 100644 index 0000000000..9b9fa16b7d --- /dev/null +++ b/loopx/extensions/lark/goal_channel_blocked_notice.py @@ -0,0 +1,356 @@ +"""Authorized Lark delivery for typed blocked Todo notices.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ...control_plane.quota.blocked_transition_notice import ( + blocked_transition_notice_identity, + blocked_transition_notice_owner_reason, + build_blocked_transition_notice, +) +from ...control_plane.todos.contract import ( + TODO_STATUS_OPEN, + TODO_TERMINAL_STATUS_VALUES, + normalize_todo_status, +) +from .goal_channel_contracts import ( + BLOCKED_NOTICE_RETIRED_STATES, + BlockedNoticeReceiptState, + blocked_notice_receipt_matches_target, + binding_for_goal, + blocked_notice_auto_notify_enabled, + now_iso, + provider_idempotency_key, + read_goal_channel_binding, + save_goal_binding, + semantic_key, + serialize_goal_binding_mutation, +) +from .goal_channel_transport import ( + APP_ID_PATTERN, + CHAT_ID_PATTERN, + MESSAGE_ID_PATTERN, + auth_verified, + call, + chat_verified, + find_first_string, + json_payload, + lark_args, + message_readback_verified, + verified_app_id, +) +from .presentation.kanban import ( + DEFAULT_CLI_BIN, + CommandRunner, + default_subprocess_runner, +) + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _active_notices( + status: Mapping[str, Any], goal_id: str, quota_packet: Mapping[str, Any] +) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: + fallback = _mapping(quota_packet.get("blocked_priority_fallback")) + selected = _mapping(fallback.get("selected_executable")) + notices = { + str(value["blocker_identity"]): dict(value) + for value in fallback.get("blocked_transition_notices", []) + if isinstance(value, Mapping) + and value.get("blocker_identity") and value.get("blocker_revision") + and not value.get("superseded_by") + } + observed: dict[str, dict[str, Any]] = {} + queue = _mapping(status.get("attention_queue")) + for goal in queue.get("items", []): + if not isinstance(goal, Mapping) or str(goal.get("goal_id") or "") != goal_id: + continue + for lane in ("agent_todos", "user_todos"): + group = _mapping(goal.get(lane)) + for item in group.get("items", []): + if not isinstance(item, Mapping): + continue + identity = blocked_transition_notice_identity(item) + if identity is None: + continue + observed[identity] = dict(item) + notice = build_blocked_transition_notice( + item, selected_executable=selected or None + ) + # Explicit canonical rows supersede an older quota projection. + if notice is not None and not notice.get("superseded_by"): + notices[identity] = notice + else: + notices.pop(identity, None) + return list(notices.values()), observed + + +MAX_BLOCKED_NOTICE_EFFECTS_PER_REFRESH = 8 + + +def _reconcile_receipts( + receipts: dict[str, Any], observed: Mapping[str, Mapping[str, Any]], +) -> bool: + changed = False + for key, receipt in list(receipts.items()): + if (not isinstance(receipt, Mapping) + or receipt.get("kind") != "blocked_notice" + or receipt.get("state") in BLOCKED_NOTICE_RETIRED_STATES): + continue + item = observed.get(str(receipt.get("blocker_identity") or "")) + if item is None: + continue # A missing or paginated row is not a recovery observation. + status = normalize_todo_status(item.get("status")) + notice = build_blocked_transition_notice(item) + state = None + if item.get("superseded_by"): + state = BlockedNoticeReceiptState.SUPERSEDED + elif notice is not None: + if notice["blocker_revision"] != receipt.get("blocker_revision"): + state = BlockedNoticeReceiptState.SUPERSEDED + elif status == TODO_STATUS_OPEN: + state = BlockedNoticeReceiptState.RESUMED + elif status in TODO_TERMINAL_STATUS_VALUES: + state = BlockedNoticeReceiptState.RESOLVED + if state is not None: + receipts[key] = {**receipt, "state": state, "reconciled_at": now_iso()} + changed = True + return changed + + +def _counter(receipt: Mapping[str, Any], name: str) -> int: + value = receipt.get(name) + return value if type(value) is int and value >= 0 else 0 + + +def _receipt_for_notice( + receipts: Mapping[str, Any], notice: Mapping[str, Any], *, goal_id: str, chat_id: str, +) -> tuple[str, dict[str, Any]]: + identity, revision = str(notice["blocker_identity"]), str(notice["blocker_revision"]) + previous = [ + (key, receipt) for key, receipt in receipts.items() + if isinstance(receipt, Mapping) + and receipt.get("blocker_identity") == identity + and receipt.get("blocker_revision") == revision + and blocked_notice_receipt_matches_target(key, receipt, goal_id=goal_id, chat_id=chat_id) + ] + if previous: + key, latest = max(previous, key=lambda pair: _counter(pair[1], "generation")) + if latest.get("state") not in BLOCKED_NOTICE_RETIRED_STATES: + return key, dict(latest) + generation = _counter(latest, "generation") + 1 + else: + generation = 0 + parts = [goal_id, "lark", "blocked_notice", identity, revision, chat_id] + key = semantic_key(*parts, "generation", str(generation)) if generation else semantic_key(*parts) + return key, { + "kind": "blocked_notice", "blocker_identity": identity, "blocker_revision": revision, + "chat_id": chat_id, "generation": generation, + "state": BlockedNoticeReceiptState.PENDING, "readback_verified": False, + } + + +def _notice_message(goal_id: str, notice: Mapping[str, Any]) -> str: + reason = blocked_transition_notice_owner_reason(notice) or "A Goal task is blocked." + evidence = notice.get("evidence") + evidence_text = ( + "; ".join(str(value) for value in evidence[:4]) + if isinstance(evidence, list) + else "" + ) + responsible = str(notice.get("responsible_party") or "unknown") + recovery = _mapping(notice.get("recovery_condition")) + return "\n".join( + filter( + None, + ( + f"LoopX Goal {goal_id}: blocked task", + reason, + f"Evidence: {evidence_text}" if evidence_text else "", + f"Responsible: {responsible}", + f"Recovery: {recovery.get('description') or 'clear the blocker'}", + "Owner action required." + if notice.get("owner_must_act") is True + else "No owner action required.", + ), + ) + ) + + +@serialize_goal_binding_mutation +def deliver_blocked_notices( + *, + goal_id: str, + binding_path: Path, + status: Mapping[str, Any], + quota_packet: Mapping[str, Any], + provider_target: Mapping[str, Any] | None = None, + external_sink_delivery_authorized: bool, + runner: CommandRunner = default_subprocess_runner, +) -> dict[str, Any]: + payload = read_goal_channel_binding(binding_path) + raw_binding = binding_for_goal(payload, goal_id) + binding = binding_for_goal(payload, goal_id, provider_target=provider_target) + enabled = blocked_notice_auto_notify_enabled(raw_binding) + notices, observed = _active_notices(status, goal_id, quota_packet) + result: dict[str, Any] = { + "schema_version": "loopx_goal_channel_blocked_notice_delivery_v0", + "ok": True, + "enabled": enabled, + "status": "not_configured" if raw_binding is None else "disabled", + "notice_count": len(notices), + "delivered_count": 0, + "pending_count": len(notices), + "external_write_performed": False, + "readback_verified": False, + "delivery_postcondition": {"satisfied": True, "blocks_delivery": False}, + } + if not enabled: + return result + result["delivery_postcondition"]["satisfied"] = not notices + if not external_sink_delivery_authorized: + result["status"] = "external_sink_suppressed" + return result + if binding is None or binding.get("enabled") is not True: + result.update( + ok=False, + status="channel_binding_incomplete", + blocker="channel_binding_incomplete", + ) + return result + if raw_binding.get("target_ref") and provider_target is None: + result.update( + ok=False, + status="provider_target_missing", + blocker="provider_target_missing", + ) + return result + channel = _mapping(binding.get("channel")) + identity_config = _mapping(binding.get("identity")) + chat_id = str(channel.get("chat_id") or "") + cli_bin = str(identity_config.get("cli_bin") or DEFAULT_CLI_BIN) + profile = str(identity_config.get("sender_profile") or "") or None + app_id = str(identity_config.get("bot_app_id") or "") + if notices and not ( + identity_config.get("sender_identity") == "bot" + and APP_ID_PATTERN.fullmatch(app_id) + and auth_verified( + runner=runner, + cli_bin=cli_bin, + profile=profile, + identity="bot", + expected_bot_name=str(identity_config.get("bot_display_name") or "") + or None, + ) + and verified_app_id(runner=runner, cli_bin=cli_bin, profile=profile) == app_id + ): + result.update( + ok=False, + status="provider_identity_unverified", + blocker="provider_identity_unverified", + ) + return result + if notices and ( + not CHAT_ID_PATTERN.fullmatch(chat_id) + or not chat_verified( + runner=runner, + cli_bin=cli_bin, + profile=profile, + identity="bot", + chat_id=chat_id, + ) + ): + result.update( + ok=False, + status="channel_membership_unverified", + blocker="channel_membership_unverified", + ) + return result + receipts = _mapping(raw_binding.get("receipts")) + changed = _reconcile_receipts(receipts, observed) + candidates = [] + for notice in notices: + key, receipt = _receipt_for_notice(receipts, notice, goal_id=goal_id, chat_id=chat_id) + if key not in receipts: + receipts[key] = receipt + changed = True + candidates.append((notice, key, receipt)) + # Unattempted effects precede retries, so a persistent failure cannot starve + # later candidates. Already verified receipts never consume the send budget. + candidates.sort(key=lambda candidate: _counter(candidate[2], "attempt_count")) + delivered = attempts = deferred = 0 + for notice, key, existing in candidates: + if (existing.get("readback_verified") is True + and existing.get("state") == BlockedNoticeReceiptState.DELIVERED): + delivered += 1 + continue + if attempts >= MAX_BLOCKED_NOTICE_EFFECTS_PER_REFRESH: + deferred += 1 + continue + attempts += 1 + existing = { + **existing, "chat_id": chat_id, + "attempt_count": _counter(existing, "attempt_count") + 1, + } + message = _notice_message(goal_id, notice) + send = call( + runner, + lark_args( + cli_bin=cli_bin, profile=profile, + tail=[ + "im", "+messages-send", "--chat-id", chat_id, + "--text", message, "--idempotency-key", provider_idempotency_key(key), + "--as", "bot", "--format", "json", + ], + ), + ) + message_id = find_first_string(json_payload(send), {"message_id"}, MESSAGE_ID_PATTERN) or "" + if send.get("returncode") != 0 or not message_id: + receipts[key] = { + **existing, "state": BlockedNoticeReceiptState.PENDING, + "readback_verified": False, "failure_code": "provider_api_failed", + } + result.update(ok=False, status="provider_api_failed", blocker="provider_api_failed") + else: + result["external_write_performed"] = True + verified = message_readback_verified( + runner=runner, cli_bin=cli_bin, profile=profile, identity="bot", + message_id=message_id, expected_text=message, + ) + sent_at = now_iso() + receipts[key] = { + **existing, "message_id": message_id, "sent_at": sent_at, + "verified_at": sent_at if verified else None, "readback_verified": verified, + "state": (BlockedNoticeReceiptState.DELIVERED if verified + else BlockedNoticeReceiptState.SENT_UNVERIFIED), + } + receipts[key].pop("failure_code", None) + if verified: + delivered += 1 + else: + result.update(ok=False, status="sent_unverified", blocker="readback_mismatch") + mutable = {**raw_binding, "receipts": receipts} + save_goal_binding( + binding_path=binding_path, payload=payload, goal_id=goal_id, binding=mutable, + ) + changed = False + if changed: + save_goal_binding( + binding_path=binding_path, payload=payload, goal_id=goal_id, + binding={**raw_binding, "receipts": receipts}, + ) + result["delivered_count"] = delivered + result["pending_count"] = len(notices) - delivered + result["deferred_count"] = deferred + result["readback_verified"] = bool(notices and delivered == len(notices)) + result["delivery_postcondition"]["satisfied"] = not result["pending_count"] + if result["ok"]: + result["status"] = ( + "pending" if result["pending_count"] else "sent_verified" if notices else "no_active_blocker" + ) + return result diff --git a/loopx/extensions/lark/goal_channel_contracts.py b/loopx/extensions/lark/goal_channel_contracts.py index 97f8bdd25a..7ffe064eb9 100644 --- a/loopx/extensions/lark/goal_channel_contracts.py +++ b/loopx/extensions/lark/goal_channel_contracts.py @@ -25,6 +25,8 @@ # now own repeat-notification timing. DEFAULT_GATE_COOLDOWN_SECONDS = 3600 HUMAN_GATE_AUTO_NOTIFY_SETTING = "human_gate_auto_notify_enabled" +BLOCKED_NOTICE_AUTO_NOTIFY_SETTING = "blocked_notice_auto_notify_enabled" +BLOCKED_NOTICE_AUTO_NOTIFY_MARKER_SCHEMA_VERSION = "loopx_goal_channel_blocked_notice_marker_v0" HUMAN_GATE_AUTO_NOTIFY_MARKER_SCHEMA_VERSION = ( "loopx_goal_channel_auto_notify_marker_v0" ) @@ -43,6 +45,39 @@ ) +class BlockedNoticeReceiptState(str, Enum): + """Provider delivery lifecycle; it does not mutate canonical Todo state.""" + + PENDING = "pending" + SENT_UNVERIFIED = "sent_unverified" + DELIVERED = "delivered" + RESUMED = "resumed" + RESOLVED = "resolved" + SUPERSEDED = "superseded" + + +BLOCKED_NOTICE_RETIRED_STATES = frozenset({ + BlockedNoticeReceiptState.RESUMED, + BlockedNoticeReceiptState.RESOLVED, + BlockedNoticeReceiptState.SUPERSEDED, +}) + + +def blocked_notice_receipt_matches_target( + key: str, receipt: Mapping[str, Any], *, goal_id: str, chat_id: str, +) -> bool: + if not chat_id or receipt.get("kind") != "blocked_notice": + return False + if receipt.get("chat_id"): + return receipt["chat_id"] == chat_id + # Older initial receipts encode the destination in their semantic key. + # An unbound historical/reopened receipt cannot prove this target's delivery. + return key == semantic_key( + goal_id, "lark", "blocked_notice", str(receipt.get("blocker_identity") or ""), + str(receipt.get("blocker_revision") or ""), chat_id, + ) + + class LarkTopicEventDecisionReason(str, Enum): """Typed, content-free outcome of Goal Topic routing.""" @@ -302,6 +337,16 @@ def human_gate_auto_notify_enabled(binding: Mapping[str, Any] | None) -> bool: return automation.get(HUMAN_GATE_AUTO_NOTIFY_SETTING) is True +def blocked_notice_auto_notify_enabled(binding: Mapping[str, Any] | None) -> bool: + automation = ( + binding.get("automation") + if isinstance(binding, Mapping) + and isinstance(binding.get("automation"), Mapping) + else {} + ) + return automation.get(BLOCKED_NOTICE_AUTO_NOTIFY_SETTING) is True + + def human_gate_auto_notify_marker_path( binding_path: Path, goal_id: str, @@ -349,6 +394,46 @@ def clear_human_gate_auto_notify_marker(path: Path) -> None: path.expanduser().unlink(missing_ok=True) +def blocked_notice_auto_notify_marker_path(binding_path: Path, goal_id: str) -> Path: + human_marker = human_gate_auto_notify_marker_path(binding_path, goal_id) + return human_marker.with_name( + human_marker.name.replace( + ".human-gate-auto-notify.json", ".blocked-notice-auto-notify.json" + ) + ) + + +def blocked_notice_auto_notify_marker_enabled(path: Path) -> bool: + marker_path = path.expanduser() + if not marker_path.exists(): + return False + try: + payload = json.loads(marker_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + # A damaged enabled marker must leave the sink visibly failed. + return True + return bool( + isinstance(payload, Mapping) + and payload.get("schema_version") + == BLOCKED_NOTICE_AUTO_NOTIFY_MARKER_SCHEMA_VERSION + and payload.get("enabled") is True + ) + + +def write_blocked_notice_auto_notify_marker(path: Path) -> None: + write_private_json_atomic( + path, + { + "schema_version": BLOCKED_NOTICE_AUTO_NOTIFY_MARKER_SCHEMA_VERSION, + "enabled": True, + }, + ) + + +def clear_blocked_notice_auto_notify_marker(path: Path) -> None: + path.expanduser().unlink(missing_ok=True) + + def quota_human_gate_identity(quota_packet: Mapping[str, Any]) -> str: summary = quota_packet.get("user_todo_summary") summary = summary if isinstance(summary, Mapping) else {} diff --git a/loopx/extensions/lark/goal_channel_lifecycle.py b/loopx/extensions/lark/goal_channel_lifecycle.py index 76d597e54d..06dc77f3e4 100644 --- a/loopx/extensions/lark/goal_channel_lifecycle.py +++ b/loopx/extensions/lark/goal_channel_lifecycle.py @@ -15,6 +15,9 @@ from . import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION from .goal_channel_contracts import ( binding_for_goal, + blocked_notice_auto_notify_enabled, + blocked_notice_auto_notify_marker_enabled, + blocked_notice_auto_notify_marker_path, default_goal_channel_binding_path, human_gate_auto_notify_enabled, human_gate_auto_notify_marker_enabled, @@ -23,6 +26,7 @@ read_goal_channel_binding, ) from .goal_channel_runtime import auto_notify_lark_goal_channel_gate +from .goal_channel_blocked_notice import deliver_blocked_notices from .goal_channel_message_delivery import delivery_send_failure from .identity_shapes import LARK_MESSAGE_ID_SEARCH as MESSAGE_ID_PATTERN from .goal_channel_transport import ( @@ -430,3 +434,116 @@ def admit_delivery() -> None: if activation is not None: result["extension_activation"] = activation return result + + +def blocked_notice_sync_failure( + *, registry_path: Path, goal_id: str, exception: Exception +) -> dict[str, Any]: + configured = False + try: + registry = load_registry(registry_path) + route = resolve_goal_source_runtime_route( + registry_path=registry_path, goal_id=goal_id, registry=registry + ) + source_registry = Path(str(route["source_registry"])) + if source_registry.parent.name == ".loopx": + binding_path = default_goal_channel_binding_path(source_registry) + configured = blocked_notice_auto_notify_enabled( + binding_for_goal(read_goal_channel_binding(binding_path), goal_id) + ) or blocked_notice_auto_notify_marker_enabled( + blocked_notice_auto_notify_marker_path(binding_path, goal_id) + ) + except (OSError, ValueError): + pass + return { + "schema_version": "loopx_goal_channel_blocked_notice_delivery_v0", + "ok": not configured, + "enabled": configured, + "status": "failed" if configured else "not_configured", + "external_write_performed": False, + "external_write_status": "unknown" if configured else "not_attempted", + "readback_verified": False, + "blocker": "blocked_notice_sync_failed" if configured else None, + "failure_reason": _exception_reason(exception) if configured else None, + "delivery_postcondition": { + "satisfied": not configured, + "blocks_delivery": False, + }, + } + + +def sync_blocked_notice_after_refresh( + *, + registry_path: Path, + runtime_root_override: str | None, + goal_id: str, + agent_id: str | None, + external_sink_delivery_authorized: bool, + runner: CommandRunner = default_subprocess_runner, +) -> dict[str, Any]: + invoked_registry = load_registry(registry_path) + source_route = resolve_goal_source_runtime_route( + registry_path=registry_path, goal_id=goal_id, registry=invoked_registry + ) + source_registry_path = Path(str(source_route["source_registry"])) + if source_registry_path.parent.name != ".loopx": + return { + "schema_version": "loopx_goal_channel_blocked_notice_delivery_v0", + "ok": True, + "enabled": False, + "status": "project_binding_unavailable", + "external_write_performed": False, + "readback_verified": False, + "delivery_postcondition": {"satisfied": True, "blocks_delivery": False}, + } + runtime_root = ( + Path(runtime_root_override).expanduser().resolve() + if runtime_root_override + else Path(str(source_route["source_runtime_root"])) + ) + binding_path = default_goal_channel_binding_path(source_registry_path) + payload = read_goal_channel_binding(binding_path) + raw_binding = binding_for_goal(payload, goal_id) + if not blocked_notice_auto_notify_enabled(raw_binding): + return { + "schema_version": "loopx_goal_channel_blocked_notice_delivery_v0", + "ok": True, + "enabled": False, + "status": "not_configured" if raw_binding is None else "disabled", + "external_write_performed": False, + "readback_verified": False, + "delivery_postcondition": {"satisfied": True, "blocks_delivery": False}, + } + if blocked_notice_auto_notify_enabled(raw_binding): + resolve_extension_activation( + LARK_EXTENSION_ID, + state_file=default_extension_state_file(runtime_root), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,), + ) + target_name = str((raw_binding or {}).get("target_ref") or "") + target = ( + goal_channel_target_for_name( + read_goal_channel_targets(default_goal_channel_target_path(runtime_root)), + target_name, + ) + if target_name + else None + ) + status = collect_status( + registry_path=source_registry_path, + runtime_root_override=str(runtime_root), + scan_roots=[registry_project_root(source_registry_path)], + limit=20, + goal_id=goal_id, + include_public_boundary_scan=False, + ) + quota_packet = build_quota_should_run(status, goal_id=goal_id, agent_id=agent_id) + return deliver_blocked_notices( + goal_id=goal_id, + binding_path=binding_path, + status=status, + quota_packet=quota_packet, + provider_target=target, + external_sink_delivery_authorized=external_sink_delivery_authorized, + runner=runner, + ) diff --git a/loopx/extensions/lark/goal_channel_notification.py b/loopx/extensions/lark/goal_channel_notification.py index 3a74f22b28..9184a34222 100644 --- a/loopx/extensions/lark/goal_channel_notification.py +++ b/loopx/extensions/lark/goal_channel_notification.py @@ -21,7 +21,14 @@ from ...history import load_registry from ...registry import registry_goals from .goal_channel_contracts import ( + BLOCKED_NOTICE_RETIRED_STATES, + BlockedNoticeReceiptState, assert_public_packet, + binding_for_goal, + blocked_notice_receipt_matches_target, + blocked_notice_auto_notify_enabled, + blocked_notice_auto_notify_marker_enabled, + blocked_notice_auto_notify_marker_path, default_goal_channel_binding_path, human_gate_auto_notify_enabled, human_gate_auto_notify_marker_enabled, @@ -31,6 +38,11 @@ read_goal_channel_binding, ) from .goal_channel_transport import CHAT_ID_PATTERN, MESSAGE_ID_PATTERN +from .goal_channel_targets import ( + default_goal_channel_target_path, + goal_channel_target_for_name, + read_goal_channel_targets, +) GOAL_CHANNEL_NOTIFICATION_PROJECTION_SCHEMA_VERSION = ( @@ -53,6 +65,7 @@ def _unconfigured_goal_row(goal_id: str) -> dict[str, Any]: "configured": False, "enabled": False, "human_gate_auto_notify_enabled": False, + "blocked_notice_auto_notify_enabled": False, "receipt_count": 0, } @@ -77,9 +90,9 @@ def _goal_notification_row( binding_path: Path, binding_payload: Mapping[str, Any], goal_id: str, + runtime_root: Path | None = None, ) -> dict[str, Any]: - bindings = binding_payload.get("bindings") - binding = bindings.get(goal_id) if isinstance(bindings, Mapping) else None + binding = binding_for_goal(binding_payload, goal_id) if not isinstance(binding, Mapping): return _unconfigured_goal_row(goal_id) row: dict[str, Any] = { @@ -90,6 +103,12 @@ def _goal_notification_row( human_gate_auto_notify_enabled(binding) or _auto_notify_marker_state(binding_path, goal_id) ), + "blocked_notice_auto_notify_enabled": bool( + blocked_notice_auto_notify_enabled(binding) + or blocked_notice_auto_notify_marker_enabled( + blocked_notice_auto_notify_marker_path(binding_path, goal_id) + ) + ), } target_ref = _public_text(binding.get("target_ref")) if target_ref: @@ -97,10 +116,50 @@ def _goal_notification_row( receipts = binding.get("receipts") receipts = receipts if isinstance(receipts, Mapping) else {} row["receipt_count"] = len(receipts) + resolved_binding = binding + has_blocked_receipts = any( + isinstance(receipt, Mapping) and receipt.get("kind") == "blocked_notice" + for receipt in receipts.values() + ) + if binding.get("target_ref") and has_blocked_receipts: + target = None + try: + if runtime_root is not None: + target = goal_channel_target_for_name( + read_goal_channel_targets(default_goal_channel_target_path(runtime_root)), + str(binding["target_ref"]), + ) + resolved_binding = ( + binding_for_goal(binding_payload, goal_id, provider_target=target) if target else {} + ) + except (OSError, ValueError): + resolved_binding = {} + channel = resolved_binding.get("channel") if resolved_binding else None + chat_id = str(channel.get("chat_id") or "") if isinstance(channel, Mapping) else "" + blocked_receipts = [ + receipt for key, receipt in receipts.items() + if isinstance(receipt, Mapping) + and blocked_notice_receipt_matches_target(key, receipt, goal_id=goal_id, chat_id=chat_id) + ] + row["blocked_notice_delivery"] = { + "delivered_count": sum( + r.get("readback_verified") is True and r.get("state") == BlockedNoticeReceiptState.DELIVERED + for r in blocked_receipts + ), + "unverified_count": sum( + r.get("state") in {BlockedNoticeReceiptState.PENDING, BlockedNoticeReceiptState.SENT_UNVERIFIED} + for r in blocked_receipts + ), + "resolved_count": sum(r.get("state") in BLOCKED_NOTICE_RETIRED_STATES for r in blocked_receipts), + } verified: list[tuple[Any, str]] = [] - for receipt in receipts.values(): + for key, receipt in receipts.items(): if not isinstance(receipt, Mapping): continue + if receipt.get("kind") == "blocked_notice" and not blocked_notice_receipt_matches_target( + key, receipt, goal_id=goal_id, chat_id=chat_id, + ): + continue moment = parse_time(receipt.get("verified_at")) if moment is not None: verified.append((moment, str(receipt.get("verified_at")))) @@ -162,6 +221,7 @@ def build_goal_channel_notification_projection( binding_path=cached_path, binding_payload=cached_payload, goal_id=current_goal_id, + runtime_root=(Path(str(route["source_runtime_root"])) if route.get("source_runtime_root") else None), ) except (OSError, ValueError): row = None diff --git a/loopx/extensions/lark/goal_channel_runtime.py b/loopx/extensions/lark/goal_channel_runtime.py index 278b2600be..93156c6173 100644 --- a/loopx/extensions/lark/goal_channel_runtime.py +++ b/loopx/extensions/lark/goal_channel_runtime.py @@ -6,6 +6,10 @@ from .goal_channel_contracts import ( binding_for_goal, + blocked_notice_auto_notify_enabled, + blocked_notice_auto_notify_marker_path, + clear_blocked_notice_auto_notify_marker, + write_blocked_notice_auto_notify_marker, clear_human_gate_auto_notify_marker, gate_message, goal_from_registry, @@ -87,7 +91,8 @@ def configure_lark_goal_channel_automation( registry: Mapping[str, Any], goal_id: str, binding_path: Path, - human_gate_auto_notify: bool, + human_gate_auto_notify: bool | None = None, + blocked_notice_auto_notify: bool | None = None, execute: bool = False, ) -> dict[str, Any]: goal_from_registry(registry, goal_id) @@ -103,7 +108,11 @@ def configure_lark_goal_channel_automation( blocker="channel_binding_missing", public_summary="configure the Goal Channel before enabling automation", ) - if human_gate_auto_notify and binding.get("enabled") is not True: + if human_gate_auto_notify is None and blocked_notice_auto_notify is None: + raise ValueError("choose an automation setting") + if ( + human_gate_auto_notify is True or blocked_notice_auto_notify is True + ) and binding.get("enabled") is not True: return operation_packet( ok=False, goal_id=goal_id, @@ -113,13 +122,24 @@ def configure_lark_goal_channel_automation( blocker="channel_binding_incomplete", public_summary="complete Goal Channel setup before enabling automation", ) - current = human_gate_auto_notify_enabled(binding) - changed = current != human_gate_auto_notify - marker_path = human_gate_auto_notify_marker_path(binding_path, goal_id) + current_human = human_gate_auto_notify_enabled(binding) + current_blocked = blocked_notice_auto_notify_enabled(binding) + next_human = ( + current_human if human_gate_auto_notify is None else human_gate_auto_notify + ) + next_blocked = ( + current_blocked + if blocked_notice_auto_notify is None + else blocked_notice_auto_notify + ) + changed = current_human != next_human or current_blocked != next_blocked if execute and changed: mutable_binding = dict(binding) automation = _mapping(binding.get("automation")) - automation["human_gate_auto_notify_enabled"] = human_gate_auto_notify + if human_gate_auto_notify is not None: + automation["human_gate_auto_notify_enabled"] = next_human + if blocked_notice_auto_notify is not None: + automation["blocked_notice_auto_notify_enabled"] = next_blocked mutable_binding["automation"] = automation save_goal_binding( binding_path=binding_path, @@ -127,11 +147,23 @@ def configure_lark_goal_channel_automation( goal_id=goal_id, binding=mutable_binding, ) - if execute: - if human_gate_auto_notify: - write_human_gate_auto_notify_marker(marker_path) + if execute and human_gate_auto_notify is not None: + human_marker = human_gate_auto_notify_marker_path(binding_path, goal_id) + if next_human: + write_human_gate_auto_notify_marker(human_marker) + else: + clear_human_gate_auto_notify_marker(human_marker) + if execute and blocked_notice_auto_notify is not None: + blocked_marker = blocked_notice_auto_notify_marker_path(binding_path, goal_id) + if next_blocked: + write_blocked_notice_auto_notify_marker(blocked_marker) else: - clear_human_gate_auto_notify_marker(marker_path) + clear_blocked_notice_auto_notify_marker(blocked_marker) + saved = ( + binding_for_goal(read_goal_channel_binding(binding_path), goal_id) + if execute + else None + ) return operation_packet( ok=True, goal_id=goal_id, @@ -139,22 +171,25 @@ def configure_lark_goal_channel_automation( execute=execute, status="configured" if execute else "preview_ready", public_summary=( - "enabled automatic human gate notifications for this Goal Channel" - if execute and human_gate_auto_notify - else "disabled automatic human gate notifications for this Goal Channel" + "updated Goal Channel automatic notification settings" if execute else "previewed the Goal Channel automation change" ), readback_verified=bool( execute - and human_gate_auto_notify_enabled( - binding_for_goal(read_goal_channel_binding(binding_path), goal_id) + and ( + human_gate_auto_notify is None + or human_gate_auto_notify_enabled(saved) == next_human + ) + and ( + blocked_notice_auto_notify is None + or blocked_notice_auto_notify_enabled(saved) == next_blocked ) - == human_gate_auto_notify ), details={ "changed": changed, - "human_gate_auto_notify_enabled": human_gate_auto_notify, + "human_gate_auto_notify_enabled": next_human, + "blocked_notice_auto_notify_enabled": next_blocked, }, ) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 86535e5a1e..81b7d018fb 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -455,7 +455,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1500, + "line": 1525, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -463,7 +463,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1588, + "line": 1613, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -663,7 +663,7 @@ }, { "site": "loopx/cli_commands/goal_channel.py::._source_context::codec_read:load_registry#1", - "line": 305, + "line": 322, "column": 14, "kind": "codec_read", "api": "load_registry", @@ -671,7 +671,7 @@ }, { "site": "loopx/cli_commands/goal_channel.py::.handle_goal_channel_command::codec_read:load_registry#1", - "line": 450, + "line": 467, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -751,7 +751,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#1", - "line": 560, + "line": 562, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -759,7 +759,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#2", - "line": 639, + "line": 641, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -1733,17 +1733,33 @@ "api": "load_registry", "classification": "codec_api" }, + { + "site": "loopx/extensions/lark/goal_channel_lifecycle.py::.blocked_notice_sync_failure::codec_read:load_registry#1", + "line": 444, + "column": 20, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/extensions/lark/goal_channel_lifecycle.py::.goal_channel_gate_sync_failure::codec_read:load_registry#1", - "line": 209, + "line": 213, "column": 32, "kind": "codec_read", "api": "load_registry", "classification": "codec_api" }, + { + "site": "loopx/extensions/lark/goal_channel_lifecycle.py::.sync_blocked_notice_after_refresh::codec_read:load_registry#1", + "line": 484, + "column": 24, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/extensions/lark/goal_channel_lifecycle.py::.sync_human_gate_after_refresh::codec_read:load_registry#1", - "line": 257, + "line": 261, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -1751,7 +1767,7 @@ }, { "site": "loopx/extensions/lark/goal_channel_lifecycle.py::.sync_human_gate_after_refresh::codec_read:load_registry#2", - "line": 268, + "line": 272, "column": 14, "kind": "codec_read", "api": "load_registry", @@ -1759,7 +1775,7 @@ }, { "site": "loopx/extensions/lark/goal_channel_notification.py::.build_goal_channel_notification_projection::codec_read:load_registry#1", - "line": 133, + "line": 192, "column": 18, "kind": "codec_read", "api": "load_registry", diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 4e03b923f7..d41cdc5eb6 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `notification_receipt_scope_and_frontier_gap` | A recovered blocker never notifies again, later notices starve, or a new channel claims delivery from an old receipt. | Canonical blocked/open/blocked transitions, full candidate frontier, destination-bound private receipts, and public readback. | Receipt lookup omitted destination/generation, recovery used an incomplete status list, or the batch limit was applied before delivered effects were removed. | Reuse canonical status and notice builders; retire only explicit recovery or supersession facts, preserve missing-row uncertainty, bind receipts to destination and durable generation, and budget pending effects with fair retries. Validate repeated same-clock recovery, more than one batch, persistent failures, target switches and default-off parity; keep historical receipts private. | | `local_state_physical_route_guard_fragmentation` | Migration reviews repeatedly find a new symlink or Windows junction route after earlier target/backup fixes passed. | Every source, destination, backup, receipt, and rollback read/copy/rename path; the shared redirect predicate; preview and near-effect negative cases on the native OS. | Individual phases used `is_symlink()` or existence checks instead of one physical-route rule, so green happy-path receipts hid reads or moves outside the declared tree. | Reuse one symlink/junction/reparse classifier at each I/O boundary, recheck immediately before effects, and fail closed with the backup retained. Cover source, target, nested runtime content, backup snapshots and rollback using synthetic substitution plus native platform negatives; keep active user state untouched. | | `synthetic_profile_telemetry_leak` | Reporting installation IDs rise during tests, isolated installs or benchmark preparation. | Synthetic state with send receipts, collector matches when authorized, child environment builders and Agent shell policy. | Environment allowlists drop CI and telemetry opt-outs while fresh homes generate new IDs. | Force collection off at synthetic install/runtime/tool-shell boundaries and test/smoke entrypoints; exercise real child CLI and typed sender against a local collector with zero-request assertions. Keep normal user collection unchanged; do not infer or delete historical test samples from platform/channel heuristics. | | `authority_cold_read_starves_runtime` | Unrelated pure Todo rules and ping time out while warm reads are fast. | Same-byte isolated cold/warm/alternating-Goal probes, original response budget, CPU profile and exact provider revision. | Synchronous retained-history verification monopolizes the shared event loop; allocation-heavy canonical key sorting amplifies it. | Preserve byte-level proof semantics, reduce codec allocations, yield between complete transaction proofs and share only identical in-flight proofs. Test real socket concurrency and late-history corruption; do not raise timeouts, weaken integrity or replay ambiguous writes. | diff --git a/tests/control_plane/test_interrupted_turn_continuation.py b/tests/control_plane/test_interrupted_turn_continuation.py index 05534f9497..90d0d909bf 100644 --- a/tests/control_plane/test_interrupted_turn_continuation.py +++ b/tests/control_plane/test_interrupted_turn_continuation.py @@ -5,12 +5,13 @@ from test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TODO_ID, _run_cli, _run_generated_cli, - _spend_run_count, _write_fixture, + _spend_run_count, _write_fixture, _configure_read_only_todo, ) def test_original_turn_can_resume_and_settle_without_mutating_todo(tmp_path: Path) -> None: project, runtime, registry = _write_fixture(tmp_path) + _configure_read_only_todo(project) prior_turn = "interrupted-original" recovery_turn = "interrupted-recovery" diff --git a/tests/extensions/test_lark_goal_channel_blocked_notice.py b/tests/extensions/test_lark_goal_channel_blocked_notice.py new file mode 100644 index 0000000000..57dd9fcbea --- /dev/null +++ b/tests/extensions/test_lark_goal_channel_blocked_notice.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from loopx.extensions.lark.goal_channel_blocked_notice import deliver_blocked_notices +from loopx.extensions.lark import goal_channel_lifecycle, goal_channel_notification +from loopx.extensions.lark.goal_channel_runtime import ( + configure_lark_goal_channel_automation, +) +from loopx.extensions.lark.goal_channel_contracts import ( + GOAL_CHANNEL_BINDING_SCHEMA_VERSION, + read_goal_channel_binding, + write_goal_channel_binding, +) + +GOAL_ID = "goal-blocked-public-fixture" +CHAT_ID = "oc_blocked_public_fixture" +MESSAGE_ID = "om_blocked_public_fixture" + + +def _binding(path: Path) -> None: + write_goal_channel_binding( + path, + { + "schema_version": GOAL_CHANNEL_BINDING_SCHEMA_VERSION, + "bindings": { + GOAL_ID: { + "goal_id": GOAL_ID, + "provider": "lark", + "enabled": True, + "channel": {"chat_id": CHAT_ID}, + "identity": { + "sender_identity": "bot", + "bot_app_id": "cli_public_fixture", + "cli_bin": "lark-cli", + }, + "automation": {"blocked_notice_auto_notify_enabled": True}, + "receipts": {}, + } + }, + }, + ) + + +def _result(data: object) -> dict[str, object]: + return { + "returncode": 0, + "stdout": json.dumps(data), + "stderr": "", + "timed_out": False, + } + + +def _runner(calls: list[list[str]], *, verify: bool = True, fail_send: bool = False): + sent: dict[str, str] = {} + + def run( + args: list[str], cwd: Path | None, timeout: float | None + ) -> dict[str, object]: + calls.append(args) + if args == ["lark-cli", "--version"]: + return _result({"version": "1.0.56"}) + if "auth" in args and "status" in args: + return _result( + { + "ok": True, + "appId": "cli_public_fixture", + "identities": { + "bot": { + "available": True, + "verified": True, + "appName": "LoopX Bot", + } + }, + } + ) + if args[-1:] == ["--help"]: + return _result({"ok": True}) + if "chats" in args and "get" in args: + return _result({"ok": True, "data": {"chat_id": CHAT_ID}}) + if "+chat-members-list" in args: + return _result( + { + "ok": True, + "data": { + "bots": [ + { + "member_id": "ou_bot_fixture", + "app_id": "cli_public_fixture", + } + ] + }, + } + ) + if "+messages-send" in args: + if fail_send: + return { + "returncode": 1, + "stdout": "", + "stderr": "rejected", + "timed_out": False, + } + sent[MESSAGE_ID] = args[args.index("--text") + 1] + return _result({"ok": True, "data": {"message_id": MESSAGE_ID}}) + if "+messages-mget" in args: + return _result( + { + "ok": True, + "data": { + "items": [ + { + "message_id": MESSAGE_ID, + "body": { + "content": sent.get(MESSAGE_ID, "") + if verify + else "other" + }, + } + ] + }, + } + ) + return _result({"ok": True}) + + return run + + +def _status( + status: str = "blocked", *, reason: str = "Required input is missing" +) -> dict[str, object]: + return { + "attention_queue": { + "items": [ + { + "goal_id": GOAL_ID, + "agent_todos": { + "items": [ + { + "todo_id": "todo_blocked_fixture", + "text": "Validate primary result", + "status": status, + "reason": reason, + "role": "agent", + } + ] + }, + } + ] + } + } + + +def _send( + path: Path, + status: dict[str, object], + calls: list[list[str]], + *, + verify: bool = True, +): + return deliver_blocked_notices( + goal_id=GOAL_ID, + binding_path=path, + status=status, + quota_packet={}, + external_sink_delivery_authorized=True, + runner=_runner(calls, verify=verify), + ) + + +def test_blocked_notice_send_readback_dedup_and_material_revision( + tmp_path: Path, +) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + calls: list[list[str]] = [] + first = _send(path, _status(), calls) + assert first["status"] == "sent_verified" + assert first["delivered_count"] == 1 + assert len([x for x in calls if "+messages-send" in x]) == 1 + second = _send(path, _status(), calls) + assert second["delivered_count"] == 1 + assert len([x for x in calls if "+messages-send" in x]) == 1 + changed = _send(path, _status(reason="Dependency changed"), calls) + assert changed["delivered_count"] == 1 + assert len([x for x in calls if "+messages-send" in x]) == 2 + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert len(receipts) == 2 + assert all(row["readback_verified"] is True for row in receipts.values()) + + +def test_blocked_notice_unverified_is_not_claimed_as_delivered(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + result = _send(path, _status(), [], verify=False) + assert result["status"] == "sent_unverified" + assert result["pending_count"] == 1 + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert next(iter(receipts.values()))["state"] == "sent_unverified" + + +def test_blocked_notice_disabled_keeps_human_gate_setting(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + registry = {"goals": [{"id": GOAL_ID}]} + result = configure_lark_goal_channel_automation( + registry=registry, + goal_id=GOAL_ID, + binding_path=path, + blocked_notice_auto_notify=False, + execute=True, + ) + assert result["ok"] and result["readback_verified"] + assert _send(path, _status(), [])["status"] == "disabled" + stored = read_goal_channel_binding(path)["bindings"][GOAL_ID]["automation"] + assert stored["blocked_notice_auto_notify_enabled"] is False + assert "human_gate_auto_notify_enabled" not in stored + + +def test_blocked_notice_explicit_resolution_is_reconciled(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + _send(path, _status(), []) + result = _send(path, _status(status="done"), []) + assert result["status"] == "no_active_blocker" + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert next(iter(receipts.values()))["state"] == "resolved" + + +def test_reopened_same_blocker_delivers_new_transition(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + calls: list[list[str]] = [] + _send(path, _status(), calls) + _send(path, _status(status="done"), calls) + reopened = _send(path, _status(), calls) + assert reopened["status"] == "sent_verified" + assert len([args for args in calls if "+messages-send" in args]) == 2 + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert len(receipts) == 2 + assert {receipt["state"] for receipt in receipts.values()} == { + "resolved", + "delivered", + } + + +def test_owner_blocker_requests_action_and_agent_blocker_does_not( + tmp_path: Path, +) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + calls: list[list[str]] = [] + status = { + "attention_queue": { + "items": [ + { + "goal_id": GOAL_ID, + "user_todos": { + "items": [ + { + "todo_id": "todo_owner_fixture", + "text": "Approve the change", + "status": "blocked", + "reason": "Owner decision is missing", + "role": "user", + } + ] + }, + } + ] + } + } + result = _send(path, status, calls) + assert result["status"] == "sent_verified" + messages = [ + args[args.index("--text") + 1] for args in calls if "+messages-send" in args + ] + assert len(messages) == 1 and "Owner action required." in messages[0] + calls.clear() + _send(path, _status(), calls) + agent_message = next( + args[args.index("--text") + 1] for args in calls if "+messages-send" in args + ) + assert "No owner action required." in agent_message + + +def test_failed_provider_send_stays_pending_for_retry(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + calls: list[list[str]] = [] + result = deliver_blocked_notices( + goal_id=GOAL_ID, + binding_path=path, + status=_status(), + quota_packet={}, + external_sink_delivery_authorized=True, + runner=_runner(calls, fail_send=True), + ) + assert result["status"] == "provider_api_failed" + assert result["pending_count"] == 1 and not result["readback_verified"] + receipt = next( + iter(read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"].values()) + ) + assert receipt["state"] == "pending" + assert "message_id" not in receipt + retried = _send(path, _status(), calls) + assert retried["status"] == "sent_verified" + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert len(receipts) == 1 + + +def test_material_refresh_uses_authorized_sink_and_preserves_suppression( + tmp_path: Path, monkeypatch +) -> None: + registry_path = tmp_path / ".loopx" / "registry.json" + registry_path.parent.mkdir(parents=True) + registry_path.write_text("{}", encoding="utf-8") + _binding(registry_path.with_name("goal-channel.json")) + monkeypatch.setattr( + goal_channel_lifecycle, + "load_registry", + lambda path: {"goals": [{"id": GOAL_ID}]}, + ) + monkeypatch.setattr( + goal_channel_lifecycle, + "resolve_goal_source_runtime_route", + lambda **kwargs: { + "source_registry": str(registry_path), + "source_runtime_root": str(tmp_path / "runtime"), + }, + ) + monkeypatch.setattr( + goal_channel_lifecycle, + "resolve_extension_activation", + lambda *args, **kwargs: {"ok": True}, + ) + monkeypatch.setattr( + goal_channel_lifecycle, "collect_status", lambda **kwargs: _status() + ) + monkeypatch.setattr( + goal_channel_lifecycle, "build_quota_should_run", lambda *args, **kwargs: {} + ) + calls: list[list[str]] = [] + suppressed = goal_channel_lifecycle.sync_blocked_notice_after_refresh( + registry_path=registry_path, + runtime_root_override=None, + goal_id=GOAL_ID, + agent_id=None, + external_sink_delivery_authorized=False, + runner=_runner(calls), + ) + assert suppressed["status"] == "external_sink_suppressed" + assert not any("+messages-send" in args for args in calls) + delivered = goal_channel_lifecycle.sync_blocked_notice_after_refresh( + registry_path=registry_path, + runtime_root_override=None, + goal_id=GOAL_ID, + agent_id=None, + external_sink_delivery_authorized=True, + runner=_runner(calls), + ) + assert delivered["status"] == "sent_verified" + assert delivered["readback_verified"] is True + + +def test_public_status_projects_verified_count_without_private_provider_ids(tmp_path: Path, monkeypatch) -> None: + registry_path = tmp_path / ".loopx" / "registry.json" + registry_path.parent.mkdir(parents=True) + path = registry_path.with_name("goal-channel.json") + _binding(path) + _send(path, _status(), []) + monkeypatch.setattr(goal_channel_notification, "resolve_goal_source_runtime_route", lambda **kwargs: { + "source_registry": str(registry_path), + }) + projection = goal_channel_notification.build_goal_channel_notification_projection( + registry_path=registry_path, registry={"goals": [{"id": GOAL_ID}]}, goal_id=GOAL_ID, + ) + row = projection["goals"][0] + assert row["blocked_notice_auto_notify_enabled"] is True + assert row["blocked_notice_delivery"] == { + "delivered_count": 1, "unverified_count": 0, "resolved_count": 0, + } + assert CHAT_ID not in json.dumps(projection) + assert MESSAGE_ID not in json.dumps(projection) diff --git a/tests/extensions/test_lark_goal_channel_blocked_notice_receipts.py b/tests/extensions/test_lark_goal_channel_blocked_notice_receipts.py new file mode 100644 index 0000000000..f1f3b312be --- /dev/null +++ b/tests/extensions/test_lark_goal_channel_blocked_notice_receipts.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.extensions.lark import goal_channel_lifecycle, goal_channel_notification +from loopx.extensions.lark.goal_channel_blocked_notice import deliver_blocked_notices +from loopx.extensions.lark.goal_channel_contracts import read_goal_channel_binding, write_goal_channel_binding +from tests.extensions.test_lark_goal_channel_blocked_notice import ( + CHAT_ID, GOAL_ID, _binding, _runner, _send, _status, +) + + +@pytest.mark.parametrize("recovery_status,receipt_state", [("open", "resumed"), ("done", "resolved")]) +def test_repeated_recovery_reopens_delivery_without_clock_or_dict_order_dependency( + tmp_path: Path, monkeypatch, recovery_status: str, receipt_state: str, +) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + # Separate transitions can be observed within the same timestamp precision. + monkeypatch.setattr("loopx.extensions.lark.goal_channel_blocked_notice.now_iso", lambda: "2026-01-01T00:00:00Z") + calls: list[list[str]] = [] + for _ in range(3): + _send(path, _status(), calls) + _send(path, _status(recovery_status, reason=""), calls) + payload = read_goal_channel_binding(path) + binding = payload["bindings"][GOAL_ID] + binding["receipts"] = dict(reversed(list(binding["receipts"].items()))) + write_goal_channel_binding(path, payload) + sends = [args for args in calls if "+messages-send" in args] + assert len(sends) == 3 + assert len({args[args.index("--idempotency-key") + 1] for args in sends}) == 3 + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert len(receipts) == 3 + assert {row["state"] for row in receipts.values()} == {receipt_state} + + +@pytest.mark.parametrize("observation", ["missing", "unknown", "waiting"]) +def test_incomplete_or_still_blocked_observation_does_not_retire_receipt( + tmp_path: Path, observation: str, +) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + _send(path, _status(), []) + status = _status() + group = status["attention_queue"]["items"][0]["agent_todos"] + if observation == "missing": + group["items"] = [] + group["truncated"] = True + elif observation == "unknown": + group["items"][0].pop("status") + else: + group["items"][0].update(status="open", resume_when="todo_done:todo_dependency", resume_ready=False) + _send(path, status, []) + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert all(row["state"] not in {"resolved", "resumed"} for row in receipts.values()) + + +def test_delivery_budget_applies_to_pending_effects_across_refreshes(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + status = _status() + group = status["attention_queue"]["items"][0]["agent_todos"] + template = group["items"][0] + group["items"] = [{**template, "todo_id": f"todo_blocker_{index}"} for index in range(17)] + calls: list[list[str]] = [] + for expected_delivered in (8, 16, 17, 17): + result = _send(path, status, calls) + assert result["notice_count"] == 17 + assert result["delivered_count"] == expected_delivered + assert result["pending_count"] == 17 - expected_delivered + assert result["readback_verified"] is (expected_delivered == 17) + assert result["status"] == ("sent_verified" if expected_delivered == 17 else "pending") + assert len([args for args in calls if "+messages-send" in args]) == expected_delivered + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert sum(row["state"] == "pending" for row in receipts.values()) == 17 - expected_delivered + assert result["deferred_count"] == 17 - expected_delivered + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert len(receipts) == 17 + assert all(row["state"] == "delivered" for row in receipts.values()) + + +@pytest.mark.parametrize("named_target", [False, True]) +def test_switching_channel_preserves_history_and_requires_current_destination_readback( + tmp_path: Path, named_target: bool, +) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + calls: list[list[str]] = [] + for chat_id in (CHAT_ID, "oc_second_public_fixture", "oc_second_public_fixture"): + payload = read_goal_channel_binding(path) + binding = payload["bindings"][GOAL_ID] + target = None + if named_target: + binding["target_ref"] = "notice-target" + target = {"name": "notice-target", "provider": "lark", + "channel": {"chat_id": chat_id}, "identity": binding["identity"]} + else: + binding["channel"]["chat_id"] = chat_id + write_goal_channel_binding(path, payload) + result = deliver_blocked_notices( + goal_id=GOAL_ID, binding_path=path, status=_status(), quota_packet={}, + provider_target=target, external_sink_delivery_authorized=True, runner=_runner(calls), + ) + assert result["readback_verified"] is True + sends = [args for args in calls if "+messages-send" in args] + assert [args[args.index("--chat-id") + 1] for args in sends] == [CHAT_ID, "oc_second_public_fixture"] + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert {row["chat_id"] for row in receipts.values()} == {CHAT_ID, "oc_second_public_fixture"} + + +def test_persistent_failures_do_not_starve_unattempted_effects(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + status = _status() + group = status["attention_queue"]["items"][0]["agent_todos"] + template = group["items"][0] + group["items"] = [{**template, "todo_id": f"todo_retry_{i}", "text": f"Blocked task {i}"} for i in range(9)] + calls: list[list[str]] = [] + for _ in range(2): + result = deliver_blocked_notices( + goal_id=GOAL_ID, binding_path=path, status=status, quota_packet={}, + external_sink_delivery_authorized=True, runner=_runner(calls, fail_send=True), + ) + assert result["pending_count"] == 9 and result["deferred_count"] == 1 + sends = [args for args in calls if "+messages-send" in args] + assert len(sends) == 16 # Eight attempts per refresh, even when transport fails. + assert "Blocked task 8" in sends[8][sends[8].index("--text") + 1] + + +def test_superseded_blocker_retires_once_without_resending(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + calls: list[list[str]] = [] + _send(path, _status(), calls) + status = _status() + status["attention_queue"]["items"][0]["agent_todos"]["items"][0]["superseded_by"] = "todo_successor" + for _ in range(2): + assert _send(path, status, calls)["status"] == "no_active_blocker" + assert len([args for args in calls if "+messages-send" in args]) == 1 + receipts = read_goal_channel_binding(path)["bindings"][GOAL_ID]["receipts"] + assert len(receipts) == 1 and next(iter(receipts.values()))["state"] == "superseded" + + +def test_return_to_earlier_cause_is_a_new_material_transition(tmp_path: Path) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + calls: list[list[str]] = [] + for reason in ("Input missing", "Dependency missing", "Input missing"): + _send(path, _status(reason=reason), calls) + sends = [args for args in calls if "+messages-send" in args] + assert len(sends) == 3 + assert len({args[args.index("--idempotency-key") + 1] for args in sends}) == 3 + + +@pytest.mark.parametrize("enabled,authorized", [(False, True), (True, False)]) +def test_disabled_and_suppressed_delivery_leave_binding_and_transport_untouched( + tmp_path: Path, enabled: bool, authorized: bool, +) -> None: + path = tmp_path / "goal-channel.json" + _binding(path) + payload = read_goal_channel_binding(path) + payload["bindings"][GOAL_ID]["automation"]["blocked_notice_auto_notify_enabled"] = enabled + write_goal_channel_binding(path, payload) + before = path.read_bytes() + calls: list[list[str]] = [] + result = deliver_blocked_notices( + goal_id=GOAL_ID, binding_path=path, status=_status(), quota_packet={}, + external_sink_delivery_authorized=authorized, runner=_runner(calls), + ) + assert result["status"] == ("external_sink_suppressed" if enabled else "disabled") + assert not calls and path.read_bytes() == before + + +def test_default_off_refresh_does_not_enter_provider_or_notice_projection(tmp_path: Path, monkeypatch) -> None: + registry_path = tmp_path / ".loopx/registry.json" + registry_path.parent.mkdir() + path = registry_path.with_name("goal-channel.json") + _binding(path) + payload = read_goal_channel_binding(path) + payload["bindings"][GOAL_ID]["automation"].pop("blocked_notice_auto_notify_enabled") + write_goal_channel_binding(path, payload) + before = path.read_bytes() + monkeypatch.setattr(goal_channel_lifecycle, "load_registry", lambda path: {}) + monkeypatch.setattr(goal_channel_lifecycle, "resolve_goal_source_runtime_route", lambda **kwargs: { + "source_registry": str(registry_path), "source_runtime_root": str(tmp_path / "runtime"), + }) + def unexpected(*args, **kwargs): + pytest.fail("Default-off refresh entered blocked notice activation or projection") + for name in ("resolve_extension_activation", "collect_status", "build_quota_should_run", "deliver_blocked_notices"): + monkeypatch.setattr(goal_channel_lifecycle, name, unexpected) + result = goal_channel_lifecycle.sync_blocked_notice_after_refresh( + registry_path=registry_path, runtime_root_override=None, goal_id=GOAL_ID, + agent_id=None, external_sink_delivery_authorized=True, + ) + assert result["status"] == "disabled" and path.read_bytes() == before + + +@pytest.mark.parametrize("named_target", [False, True]) +def test_public_readback_follows_current_target_and_recovery(tmp_path: Path, monkeypatch, named_target: bool) -> None: + from loopx.extensions.lark.goal_channel_targets import GOAL_CHANNEL_TARGETS_SCHEMA_VERSION + registry_path = tmp_path / ".loopx/registry.json" + registry_path.parent.mkdir() + path = registry_path.with_name("goal-channel.json") + runtime_root = tmp_path / "runtime" + runtime_root.mkdir() + _binding(path) + _send(path, _status(), []) + payload = read_goal_channel_binding(path) + binding = payload["bindings"][GOAL_ID] + target = None + if named_target: + binding["target_ref"] = "notice-target" + target = {"name": "notice-target", "provider": "lark", + "channel": {"chat_id": "oc_second_public_fixture"}, "identity": binding["identity"]} + (runtime_root / "goal-channel-targets.json").write_text(json.dumps({ + "schema_version": GOAL_CHANNEL_TARGETS_SCHEMA_VERSION, "targets": {"notice-target": target}, + }), encoding="utf-8") + else: + binding["channel"]["chat_id"] = "oc_second_public_fixture" + write_goal_channel_binding(path, payload) + monkeypatch.setattr(goal_channel_notification, "resolve_goal_source_runtime_route", lambda **kwargs: { + "source_registry": str(registry_path), "source_runtime_root": str(runtime_root), + }) + def counts(): + projection = goal_channel_notification.build_goal_channel_notification_projection( + registry_path=registry_path, registry={"goals": [{"id": GOAL_ID}]}, goal_id=GOAL_ID, + ) + assert CHAT_ID not in json.dumps(projection) + assert "oc_second_public_fixture" not in json.dumps(projection) + return projection["goals"][0]["blocked_notice_delivery"] + assert counts() == {"delivered_count": 0, "unverified_count": 0, "resolved_count": 0} + for status, expected in [(_status(), (1, 0)), (_status("open", reason=""), (0, 1))]: + deliver_blocked_notices( + goal_id=GOAL_ID, binding_path=path, status=status, quota_packet={}, + provider_target=target, external_sink_delivery_authorized=True, runner=_runner([]), + ) + assert counts() == {"delivered_count": expected[0], "unverified_count": 0, "resolved_count": expected[1]} diff --git a/tests/extensions/test_lark_goal_channel_notification.py b/tests/extensions/test_lark_goal_channel_notification.py index a72d87b4ef..8f6e21aa08 100644 --- a/tests/extensions/test_lark_goal_channel_notification.py +++ b/tests/extensions/test_lark_goal_channel_notification.py @@ -79,12 +79,19 @@ def test_goal_without_binding_is_unconfigured(tmp_path: Path) -> None: "configured": False, "enabled": False, "human_gate_auto_notify_enabled": False, + "blocked_notice_auto_notify_enabled": False, "receipt_count": 0, } ] -def test_configured_binding_projects_public_state(tmp_path: Path) -> None: +def test_configured_binding_projects_public_state(tmp_path: Path, monkeypatch) -> None: + def unexpected_target_read(*args): + raise AssertionError("A human-gate-only binding must not read blocked-notice targets") + monkeypatch.setattr( + "loopx.extensions.lark.goal_channel_notification.read_goal_channel_targets", + unexpected_target_read, + ) registry_path = _registry(tmp_path) _write_binding( tmp_path, diff --git a/tests/test_chat_activity.py b/tests/test_chat_activity.py index 2713dfc4c3..6a4d105537 100644 --- a/tests/test_chat_activity.py +++ b/tests/test_chat_activity.py @@ -261,7 +261,7 @@ def on_event(kind, payload): if position % len(cases) == 3: assert "notes.md docs/relative.md" in serialized and str(tmp_path) not in serialized # The file itself must already be clean, before a replay reader or UI can filter it. - logs = "\n".join(path.read_text() for path in root.rglob("*.events.jsonl")) + logs = "\n".join(path.read_text(encoding="utf-8") for path in root.rglob("*.events.jsonl")) assert "example value" not in logs and "/opt/example" not in logs and "/etc/example" not in logs finally: session.close() diff --git a/tests/test_self_update_runtime_activation.py b/tests/test_self_update_runtime_activation.py index 338f6a084f..cfb7fac3b2 100644 --- a/tests/test_self_update_runtime_activation.py +++ b/tests/test_self_update_runtime_activation.py @@ -770,13 +770,27 @@ def fake_run( @pytest.mark.skipif(os.name != "nt", reason="native Windows update boundary") -def test_windows_execute_update_fails_closed_without_launching_bash() -> None: +@pytest.mark.parametrize("selected_route", [False, True]) +def test_windows_execute_update_fails_closed_without_launching_bash( + tmp_path: Path, selected_route: bool, +) -> None: payload = {"ok": True, "source": {}, "plan": {}} + route = ( + { + "registry_path": tmp_path / "project/.loopx/registry.json", + "runtime_root": str(tmp_path / "selected runtime"), + } + if selected_route else {} + ) - with mock.patch("loopx.self_update.subprocess.run") as run: - updated = execute_update_plan(payload) + with ( + mock.patch("loopx.self_update.subprocess.run") as run, + mock.patch("loopx.self_update.run_archive_installer") as install, + ): + updated = execute_update_plan(payload, **route) run.assert_not_called() + install.assert_not_called() assert updated["ok"] is False assert updated["execution"]["status"] == "unsupported_platform" assert "install-windows.ps1" in updated["recommended_action"] @@ -800,7 +814,14 @@ def test_failed_authority_upgrade_does_not_activate_services_or_continue_host_up restart.assert_not_called() -@pytest.mark.parametrize("driver", ["archive_snapshot", "python_pip", "python_pipx"]) +@pytest.mark.parametrize("driver", [ + pytest.param( + "archive_snapshot", + marks=pytest.mark.skipif(os.name == "nt", reason="archive updates require a POSIX host"), + ), + "python_pip", + "python_pipx", +]) def test_update_preserves_selected_route_through_install_and_readback(tmp_path, monkeypatch, driver): registry, runtime = tmp_path / "project/.loopx/registry.json", tmp_path / "selected runtime" payload = build_update_plan(action="apply", doctor_payload=doctor_payload()) diff --git a/tests/test_windows_install.py b/tests/test_windows_install.py index 5b7241190c..2d91616330 100644 --- a/tests/test_windows_install.py +++ b/tests/test_windows_install.py @@ -11,7 +11,6 @@ import pytest from loopx import windows_install -from loopx.doctor import REQUIRED_INSTALLED_SKILL_PHRASES from loopx.skill_install_readback import PACKAGED_HOST_SKILL_IDS @@ -340,7 +339,7 @@ def test_windows_installer_preserves_externally_managed_skills(tmp_path: Path) - home = tmp_path / "home" codex_skills = home / ".codex" / "skills" agents_skills = home / ".agents" / "skills" - for skill_id in REQUIRED_INSTALLED_SKILL_PHRASES: + for skill_id in PACKAGED_HOST_SKILL_IDS: shutil.copytree(repo_root / "skills" / skill_id, agents_skills / skill_id) env = dict(os.environ)