diff --git a/apps/presentation/dashboard/src/features/personal-workspace/automation-cadence-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/automation-cadence-settings.tsx index 883977f09e..b109e8d473 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/automation-cadence-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/automation-cadence-settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, RefreshCw } from "lucide-react"; import { @@ -8,108 +8,108 @@ import { import { useWorkspaceI18n } from "./i18n"; import type { WorkspaceGoal } from "./personal-workspace-model"; -type Scope = "goal" | "agent" | "automation"; - export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGoal }>) { const { t } = useWorkspaceI18n(); const agentLanes = useMemo(() => goal.agentLanes ?? (goal.agentId ? [{ agentId: goal.agentId, label: goal.agentLabel ?? goal.agentId }] : []), [goal]); - const [scope, setScope] = useState("agent"); + const [scope, setScope] = useState<"goal" | "agent">("agent"); const [agentId, setAgentId] = useState(""); + const [automationOnly, setAutomationOnly] = useState(false); const [automationId, setAutomationId] = useState(""); const [inspection, setInspection] = useState(null); const [minutes, setMinutes] = useState("0"); const [ownerReference, setOwnerReference] = useState(""); - const [approveReduction, setApproveReduction] = useState(false); - const [preview, setPreview] = useState(null); - const [busy, setBusy] = useState<"load" | "preview" | "apply" | null>(null); + const [busy, setBusy] = useState<"load" | "save" | null>(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [reloadSequence, setReloadSequence] = useState(0); + const [requiresRefresh, setRequiresRefresh] = useState(false); + const requestSequence = useRef(0); + const saving = useRef(false); const scopedAgent = scope === "goal" ? null : agentId.trim() || null; - const scopedAutomation = scope === "automation" ? automationId.trim() || null : null; - const scopeReady = scope === "goal" || Boolean(scopedAgent && agentLanes.some((lane) => lane.agentId === scopedAgent) && (scope !== "automation" || scopedAutomation)); + const scopedAutomation = scope === "agent" && automationOnly ? automationId.trim() || null : null; + const scopeReady = scope === "goal" || Boolean(scopedAgent && agentLanes.some((lane) => lane.agentId === scopedAgent) && (!automationOnly || scopedAutomation)); useEffect(() => { - if (!scopeReady) { setInspection(null); setPreview(null); setBusy(null); return; } - let active = true; - setBusy("load"); - setError(null); - setPreview(null); + const sequence = ++requestSequence.current; + saving.current = false; setInspection(null); + setNotice(null); + setError(null); + setRequiresRefresh(false); + if (!scopeReady) { setBusy(null); return; } + setBusy("load"); fetchAutomationCadence(goal.goalId, scopedAgent, scopedAutomation) .then((result) => { - if (!active) return; + if (sequence !== requestSequence.current) return; setInspection(result); const direct = result.sources.find((source) => source.agent_id === scopedAgent && source.automation_id === scopedAutomation); - setMinutes(String(direct?.min_interval_minutes ?? result.min_interval_minutes)); + setMinutes(String(direct?.min_interval_minutes ?? 0)); setOwnerReference(""); - setApproveReduction(false); }) - .catch((reason: unknown) => { if (active) setError(reason instanceof Error ? reason.message : t("cadence.loadFailed")); }) - .finally(() => { if (active) setBusy(null); }); - return () => { active = false; }; - }, [goal.goalId, scope, scopedAgent, scopedAutomation, scopeReady, reloadSequence, t]); + .catch((reason: unknown) => { if (sequence === requestSequence.current) setError(reason instanceof Error ? reason.message : t("cadence.loadFailed")); }) + .finally(() => { if (sequence === requestSequence.current) setBusy(null); }); + return () => { requestSequence.current += 1; }; + }, [goal.goalId, scopedAgent, scopedAutomation, scopeReady, reloadSequence, t]); const directRule = inspection?.sources.find((source) => source.agent_id === scopedAgent && source.automation_id === scopedAutomation); const inheritedFloor = Math.max(0, ...(inspection?.sources.filter((source) => source !== directRule).map((source) => source.min_interval_minutes) ?? [])); const proposedMinutes = Number(minutes); const validMinutes = /^\d+$/.test(minutes) && Number.isSafeInteger(proposedMinutes) && proposedMinutes <= 525600; const reduction = validMinutes && directRule !== undefined && proposedMinutes < directRule.min_interval_minutes; - const canPreview = Boolean(inspection && scopeReady && validMinutes && ownerReference.trim() && (!reduction || approveReduction) && !busy); + const changed = proposedMinutes !== (directRule?.min_interval_minutes ?? 0); + const canSave = Boolean(inspection && scopeReady && validMinutes && changed && !requiresRefresh && !busy); - function invalidate() { setPreview(null); setNotice(null); setError(null); } + function edit() { setNotice(null); } - function change(): AutomationCadenceChange | null { - if (!inspection || !canPreview) return null; - return { + async function save() { + if (!inspection || !canSave || saving.current) return; + saving.current = true; + const sequence = requestSequence.current; + const reference = `App settings Save: ${directRule?.min_interval_minutes ?? 0} -> ${proposedMinutes} minutes (${scopedAutomation ? "automation" : scope})`; + const payload: AutomationCadenceChange = { goal_id: goal.goalId, agent_id: scopedAgent, automation_id: scopedAutomation, min_interval_minutes: proposedMinutes, expected_revision: inspection.configuration_revision, - owner_reference: ownerReference.trim(), approve_reduction: reduction && approveReduction, + owner_reference: ownerReference.trim() ? `${reference}. ${ownerReference.trim()}` : reference, + // The visible before/after value and explicit Save are the reduction intent. + approve_reduction: reduction, }; - } - - async function createPreview() { - const payload = change(); - if (!payload) return; - setBusy("preview"); setError(null); - try { setPreview(await previewAutomationCadence(payload)); } - catch (reason) { setError(reason instanceof Error ? reason.message : t("cadence.previewFailed")); } - finally { setBusy(null); } - } - - async function apply() { - const payload = change(); - if (!payload || !preview?.preview_revision) return; - setBusy("apply"); setError(null); + setBusy("save"); setError(null); try { + const preview = await previewAutomationCadence(payload); + if (sequence !== requestSequence.current) return; + if (!preview.preview_revision) throw new Error(t("cadence.saveFailed")); const result = await applyAutomationCadence(payload, preview.preview_revision); + if (sequence !== requestSequence.current) return; setInspection(result); - setPreview(null); + const direct = result.sources.find((source) => source.agent_id === scopedAgent && source.automation_id === scopedAutomation); + setMinutes(String(direct?.min_interval_minutes ?? 0)); setOwnerReference(""); - setApproveReduction(false); + setRequiresRefresh(!result.readback_verified); setNotice(t(result.readback_verified ? "cadence.applied" : "cadence.readbackChanged")); } catch (reason) { - setPreview(null); - setError(reason instanceof Error ? reason.message : t("cadence.applyFailed")); - } finally { setBusy(null); } + if (sequence !== requestSequence.current) return; + setRequiresRefresh(true); + setError(reason instanceof Error ? reason.message : t("cadence.saveFailed")); + } finally { + if (sequence === requestSequence.current) { saving.current = false; setBusy(null); } + } } return
-
-

{t("cadence.description")}

+
{ event.preventDefault(); void save(); }}> +
+
-
- {t("cadence.scope")} - {(["goal", "agent", "automation"] as const).map((option) => - )} -
- {scope !== "goal" ?
- - {scope === "automation" ? : null} -
: null} {busy === "load" ?

{t("common.loading")}

: null} {inspection ? <>
@@ -118,16 +118,21 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo ? t("cadence.directSource", { minutes: directRule.min_interval_minutes, inherited: inheritedFloor }) : scope === "goal" ? t("cadence.unconfigured") : t("cadence.inheritedSource", { minutes: inheritedFloor })}

-

{t("cadence.boundary")}

-
- - +
+
- {reduction ? : null} - {preview ?
{t("cadence.preview")}{goal.title} · {scopedAgent ?? t("cadence.scope.goal")}{scopedAutomation ? ` · ${scopedAutomation}` : ""}{t("cadence.previewValue", { minutes: preview.min_interval_minutes })}{t("cadence.previewLocked")}
: null} -
+ {!validMinutes ?

{t("cadence.invalidMinutes")}

: null} + {validMinutes && changed ?

{t("cadence.change", { before: inspection.min_interval_minutes, after: Math.max(proposedMinutes, inheritedFloor) })}{proposedMinutes < inheritedFloor ? {t("cadence.inheritedLimit", { minutes: inheritedFloor })} : null}

: null} + : null} + {scope === "agent" && scopedAgent ?
{t("cadence.automationSettings")}{automationOnly ?
: null}
: null} + {inspection ? <> +
{t("cadence.ownerReference")}
+

{t("cadence.boundary")}

+
: null} - {error ?

{error}

: null} - {notice ?

{notice}

: null} + {error ?

{error}

: null} + {notice ?

{requiresRefresh ? : null}{notice}

: null} + {error || requiresRefresh ? : null} +
; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 8ea4878369..8961ca665b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1002,15 +1002,13 @@ const en = { "settings.themeLoopxDescription": "Precise monochrome surfaces, Geist type, and quiet hairline structure.", "settings.title": "Settings", "cadence.title": "Automatic execution interval", - "cadence.description": "Choose the shortest interval LoopX may recommend for automatic runs of this Goal.", - "cadence.scope": "Applies to", + "cadence.target": "Applies to", + "cadence.chooseTarget": "Choose a Goal or Agent", "cadence.scope.goal": "Whole Goal", "cadence.scope.agent": "One Agent", - "cadence.scope.automation": "One automation", - "cadence.agentId": "Agent", - "cadence.chooseAgent": "Choose an Agent", "cadence.noAgents": "No registered Agents in this Goal", - "cadence.agentHint": "Choose from this Goal’s Agents. The minimum applies only to the selected Agent.", + "cadence.automationSettings": "Limit one automation", + "cadence.automationOnly": "Apply only to this automation", "cadence.automationId": "Automation ID", "cadence.effective": "Effective minimum", "cadence.unconfigured": "No minimum configured yet.", @@ -1019,19 +1017,20 @@ const en = { "cadence.minutes": "min", "cadence.minimum": "Minimum interval (minutes)", "cadence.zeroHint": "0 clears this scope's constraint; parent constraints still apply.", - "cadence.ownerReference": "Owner instruction or reason", - "cadence.referenceHint": "Stored locally with this policy. Do not include secrets.", - "cadence.reduction": "I intend to lower or remove this scope's existing minimum.", - "cadence.boundary": "This controls LoopX schedule recommendations. Existing Codex App timers are not changed automatically; App timer-to-hook admission has not been qualified.", - "cadence.preview": "Review change", - "cadence.previewValue": "After change: {minutes} min effective minimum", - "cadence.previewLocked": "Applying rechecks the exact policy revision. A changed policy requires a new review.", - "cadence.apply": "Apply reviewed change", - "cadence.applied": "Saved and read back from the quota policy.", - "cadence.readbackChanged": "Saved, but the policy changed before readback. Refresh to inspect the latest value.", + "cadence.ownerReference": "Add a note (optional)", + "cadence.note": "Note", + "cadence.referenceHint": "The change is recorded automatically. Notes stay on this device; do not include secrets.", + "cadence.boundary": "This sets the LoopX minimum. Codex App timers must be adjusted separately.", + "cadence.change": "Effective minimum after saving: {before} → {after} min", + "cadence.inheritedLimit": "The parent minimum of {minutes} min still applies.", + "cadence.invalidMinutes": "Enter a whole number from 0 to 525600 minutes.", + "cadence.save": "Save", + "cadence.saveReduction": "Save lower minimum", + "cadence.saving": "Saving…", + "cadence.applied": "Saved and verified.", + "cadence.readbackChanged": "The change was written, but the latest value could not be verified. Refresh before making another change.", "cadence.loadFailed": "Could not load the interval policy.", - "cadence.previewFailed": "Could not preview the interval change.", - "cadence.applyFailed": "Could not apply the interval change.", + "cadence.saveFailed": "Could not verify the interval change. Refresh before retrying.", "cadence.retry": "Refresh policy", "settings.workspaceDisplay": "Workspace display", "settings.workspaceTheme": "Workspace theme", @@ -2173,15 +2172,13 @@ const zhCN: Record = { "settings.themeLoopxDescription": "精确的黑白界面、Geist 字体与安静的细线结构。", "settings.title": "设置", "cadence.title": "自动执行间隔", - "cadence.description": "设置 LoopX 为该 Goal 的自动运行可推荐的最短间隔。", - "cadence.scope": "作用范围", + "cadence.target": "修改对象", + "cadence.chooseTarget": "请选择 Goal 或 Agent", "cadence.scope.goal": "整个 Goal", "cadence.scope.agent": "单个 Agent", - "cadence.scope.automation": "单个自动化", - "cadence.agentId": "Agent", - "cadence.chooseAgent": "请选择 Agent", "cadence.noAgents": "此 Goal 暂无已注册 Agent", - "cadence.agentHint": "从此 Goal 的 Agent 中选择;下限仅针对所选 Agent 生效。", + "cadence.automationSettings": "限定单个自动化", + "cadence.automationOnly": "仅对这个自动化生效", "cadence.automationId": "自动化 ID", "cadence.effective": "生效下限", "cadence.unconfigured": "尚未设置间隔下限。", @@ -2190,19 +2187,20 @@ const zhCN: Record = { "cadence.minutes": "分钟", "cadence.minimum": "最短间隔(分钟)", "cadence.zeroHint": "设为 0 仅清除此层约束;上层约束仍然生效。", - "cadence.ownerReference": "所有者指令或原因", - "cadence.referenceHint": "随策略保存在本机,请勿填写密钥。", - "cadence.reduction": "我明确要降低或移除此层现有的间隔下限。", - "cadence.boundary": "此设置约束 LoopX 的排程建议,不会自动修改已有 Codex App 定时任务;App 定时触发到启动前钩子的拦截尚未验证。", - "cadence.preview": "预览变更", - "cadence.previewValue": "变更后生效下限:{minutes} 分钟", - "cadence.previewLocked": "应用时会复核策略修订号;若策略已变化,需重新预览。", - "cadence.apply": "应用已预览变更", - "cadence.applied": "已保存并从 quota 策略读回。", - "cadence.readbackChanged": "已写入,但读回前策略又发生变化;请刷新核对最新值。", + "cadence.ownerReference": "添加备注(可选)", + "cadence.note": "备注", + "cadence.referenceHint": "修改会自动留痕。备注仅保存在本机,请勿填写密钥。", + "cadence.boundary": "这里设置 LoopX 的间隔下限;Codex App 定时任务需另行调整。", + "cadence.change": "保存后生效下限:{before} → {after} 分钟", + "cadence.inheritedLimit": "上层的 {minutes} 分钟下限仍然生效。", + "cadence.invalidMinutes": "请输入 0 到 525600 之间的整数分钟。", + "cadence.save": "保存", + "cadence.saveReduction": "保存并降低下限", + "cadence.saving": "保存中…", + "cadence.applied": "已保存并核对生效值。", + "cadence.readbackChanged": "已写入,但最新生效值尚未核实;请刷新后再修改。", "cadence.loadFailed": "无法读取间隔策略。", - "cadence.previewFailed": "无法预览间隔变更。", - "cadence.applyFailed": "无法应用间隔变更。", + "cadence.saveFailed": "无法核实间隔变更,请刷新后重试。", "cadence.retry": "刷新策略", "settings.workspaceDisplay": "工作区显示", "settings.workspaceTheme": "工作区主题", 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 3eb57d6b5b..c3a531506d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -552,13 +552,9 @@ .personal-language-options strong { font-size: 13px; } .personal-settings-card > footer { padding: 13px 20px; border-top: 1px solid var(--pw-line); background: #faf9f6; color: var(--pw-faint); font-size: 11px; } .personal-cadence-settings { display: grid; gap: 22px; width: min(100%, 760px); padding: 28px; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); } -.personal-cadence-intro p { margin: 0; color: var(--pw-muted); font-size: 13px; line-height: 1.55; } -.personal-cadence-scopes { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; border: 0; } -.personal-cadence-scopes legend { width: 100%; margin-bottom: 10px; font-size: 12px; font-weight: 700; } -.personal-cadence-scopes label { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; padding: 0 14px; border: 1px solid var(--pw-line-strong); border-radius: 6px; cursor: pointer; font-size: 13px; } -.personal-cadence-scopes label:has(input:checked) { border-color: var(--pw-blue); color: var(--pw-blue-ink); background: var(--pw-blue-soft); } -.personal-cadence-scopes input { accent-color: var(--pw-blue); } +.personal-cadence-form { display: grid; gap: 20px; } .personal-cadence-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.personal-cadence-target { grid-template-columns: 1fr; } .personal-cadence-fields label { display: grid; align-content: start; gap: 7px; min-width: 0; font-size: 12px; font-weight: 700; } .personal-cadence-fields input, .personal-cadence-fields select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: #fff; color: var(--pw-text); font: inherit; font-size: 13px; } .personal-cadence-fields small { color: var(--pw-muted); font-size: 11px; font-weight: 400; line-height: 1.5; } @@ -569,10 +565,13 @@ .personal-cadence-readback p { margin: 0; color: var(--pw-muted); font-size: 11px; line-height: 1.5; } .personal-cadence-boundary { display: flex; align-items: flex-start; gap: 9px; margin: 0; color: var(--pw-muted); font-size: 12px; line-height: 1.55; } .personal-cadence-boundary svg { flex: 0 0 auto; margin-top: 1px; } -.personal-cadence-reduction { display: flex; align-items: center; gap: 10px; font-size: 12px; line-height: 1.5; } -.personal-cadence-reduction input { accent-color: var(--pw-blue); } -.personal-cadence-preview { display: grid; gap: 5px; padding: 14px; border: 1px solid var(--pw-blue); border-radius: 8px; background: var(--pw-blue-soft); font-size: 13px; } -.personal-cadence-preview small { color: var(--pw-muted); line-height: 1.5; } +.personal-cadence-change { display: grid; gap: 4px; margin: 0; font-size: 13px; font-variant-numeric: tabular-nums; } +.personal-cadence-change small { color: var(--pw-muted); font-size: 12px; } +.personal-cadence-details { color: var(--pw-muted); font-size: 12px; } +.personal-cadence-details summary { width: fit-content; min-height: 44px; padding-block: 10px; cursor: pointer; } +.personal-cadence-details[open] > .personal-cadence-fields { margin-top: 8px; } +.personal-cadence-automation { display: flex; align-items: center; gap: 8px; min-height: 44px; } +.personal-cadence-automation input { accent-color: var(--pw-blue); } .personal-cadence-actions { display: flex; justify-content: flex-end; gap: 9px; padding-top: 16px; border-top: 1px solid var(--pw-line); } .personal-cadence-actions button, .personal-cadence-settings button { min-height: 44px; padding: 0 14px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: #fff; color: var(--pw-text); cursor: pointer; font: inherit; font-size: 12px; font-weight: 700; } .personal-cadence-actions button.is-primary { border-color: var(--pw-text); background: var(--pw-text); color: #fff; } diff --git a/docs/architecture/rfcs/automatic-execution-admission-v0.md b/docs/architecture/rfcs/automatic-execution-admission-v0.md index 0ea8fc8369..af70b0c69c 100644 --- a/docs/architecture/rfcs/automatic-execution-admission-v0.md +++ b/docs/architecture/rfcs/automatic-execution-admission-v0.md @@ -99,6 +99,13 @@ zero removes only that scope's constraint, preserving starts. The local CLI records caller-asserted owner intent; it is **not** an authenticated boundary against another process running as the same OS user. +In the App interval editor, selecting the target and explicitly saving the +visible before/after change supplies that intent. A free-text reason is an +optional note, not a prerequisite to saving. The editor records Save provenance +automatically, carries reduction authorization only for the displayed reduction, +and performs exact-revision preview and apply behind the single Save action. +It must not approve a different target/value or retry a conflict automatically. + Admission records retain start time, request identity and trigger time. They are independent of scheduler reset keys, Todo identity and model identity. Readback exposes effective floor, contributing scopes/revisions, next eligible @@ -233,12 +240,15 @@ that. A store record written without the phase field is read as an attempted start, so an older or hand-edited file fails closed rather than resuming. The M3 settings companion presents the quota-owned Goal/agent/automation policy -through a revision-locked local preview, apply and readback, and reports stale -configuration intent as a typed conflict instead of parsing error text. Settings -start with an explicit Agent choice from the Goal’s projected Agent list, rather -than preselecting an arbitrary Agent or filtering suggestions by its current ID. -Switching the target invalidates the preview; the preview names its Goal and -Agent. Per-Agent overrides preserve peer and Goal defaults. It does +through one explicit Save backed by revision-locked preview, apply and readback, +and reports stale configuration intent as a typed conflict instead of parsing +error text. A single target selector lists the Goal and its projected Agents; +automation-specific targeting and optional notes stay in disclosure controls. +Switching the target discards the draft. Save records target/value provenance +without requiring a free-text reason or a second reduction checkbox. The editor +shows effective inheritance before saving; stale intent and unverified readback +require refresh instead of silently retrying or reporting verified success. +Per-Agent overrides preserve peer and Goal defaults. It does not edit existing Codex App timers, and next-eligible time plus Lark/CLI wait parity remain open. The App timer-to-hook path, non-Turn launchers and live model-host promotion remain unqualified; M4 remains a design option. No existing diff --git a/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md b/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md index d0959223aa..ad37240ae1 100644 --- a/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md +++ b/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md @@ -71,6 +71,10 @@ App、Turn、前端、Lark 不得另存一套策略。共享 authority provider 降低或关闭需要显式批准。零只解除当前作用域约束,不清空启动记录。本机 CLI 记录调用者声明的 用户意图,**不构成对同一 OS 用户进程的身份鉴权沙箱**。 +App 间隔编辑器中,选定目标并明确保存已展示的前后值,就提供了这次修改的意图。 +自由文本原因是可选备注,不是保存前提。编辑器自动记录保存来源,只为已展示的降低操作携带 +降低授权,并在一次保存内部完成精确版本的预览与应用;不得批准另一个目标或值,也不得自动重试冲突。 + 准入记录保留开始时间、请求身份与触发时间,不绑定 scheduler reset key、Todo 或模型。 回读包含有效下限、来源作用域与版本、下一次可执行时间和原因。重复或过期 tick 被拒绝;到期后 先持久预留再启动。损坏状态拒绝执行;时钟回退延后准入。正确性依赖可信宿主时钟,不防护时钟操纵。 @@ -173,10 +177,13 @@ managed 启动在同一 store 内分两步:准入预留间隔位,Turn execut fail-closed,显式手动理由也无法绕过。缺少阶段字段的旧记录按"已尝试启动"读取, 旧版或手工改写的文件因此 fail-closed,而不是被当作可恢复预留。 -M3 设置页阶段成果复用 quota 权威,提供 Goal/Agent/Automation 作用域的修订号锁定 -预览、应用与读回,并把过期的配置意图作为 typed conflict 报出,而不是解析错误文案。 -默认从 Goal 投影的 Agent 列表中明确选择目标,不预选任意 Agent,也不按当前 ID 过滤候选。 -切换目标会清除旧预览;预览展示 Goal 和 Agent。单个 Agent 的下限不会修改其他 Agent 或 Goal 默认值。 +M3 设置页阶段成果复用 quota 权威,一次明确的保存在内部完成修订号锁定的预览、应用与读回, +并把过期的配置意图作为 typed conflict 报出,而不是解析错误文案。 +默认要求明确选择 Goal 或其投影列表中的 Agent,不预选任意 Agent,也不按当前 ID 过滤候选。 +Goal 和该 Goal 的 Agent 通过一个修改对象选择器呈现;单个自动化和可选备注放在折叠项中。 +切换目标会清除草稿。保存自动记录目标与前后值,不再要求填写原因或额外勾选降低下限。 +保存前展示继承后的生效下限;过期意图或未核实的读回必须刷新,不静默重试,也不展示核实成功。 +单个 Agent 的下限不会修改其他 Agent 或 Goal 默认值。 它不修改已有 Codex App 定时器;下次可运行时间及 Lark/CLI 等待反馈一致性仍未完成。 App 定时器到 hook、非 Turn launcher 及真实模型宿主推广仍未验收,M4 仍是设计选项。 本提案不激活、不改绑任何已有自动化;测试和 PR 必须区分确定性验证与宿主推广。 diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index b611d832f0..3624e7dae8 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -315,7 +315,7 @@ subsystem was not performed. Section 8 records the focused audit. | [Provider-Neutral Post-Writeback Capability Hooks v0](provider-neutral-post-writeback-capability-hooks-v0.md) | S3/S8 | Accepted; first periodic-report vertical implemented | P1: R3 return/successors reuse durable intent; isolate hook failure, no primary-transaction coupling/direct effects | | [Agent IM, LoopX, And OpenViking Collaboration v0](agent-im-openviking-collaboration-v0.md) | S3/S6/S8 | Accepted; three-owner integration unqualified | P1/P2: separate IM delivery, LoopX work authority and OV context; reconnect/revoke/source-loss cases | | [External Evidence Research Capability v0](external-evidence-research-capability-v0.md) | S8/S11 | Accepted; typed Core plan/admission/retirement and CLI slice implemented | P1: qualify one host-method and one connector execution with the same provenance receipt; then add frontend/Lark projection companions | -| [Automatic Execution Admission v0](automatic-execution-admission-v0.md) | S7/S2/S4 | Accepted; local candidate, host promotion pending | P0: App recommendation floor first; M2 atomic launch/hook qualification, M3 settings acceptance | +| [Automatic Execution Admission v0](automatic-execution-admission-v0.md) | S7/S2/S4 | Accepted; typed local policy/admission and M3 settings candidate, host promotion pending | P0: App floor/schedule readback; M3 ordinary single-Save settings with optional notes, inheritance/conflict/readback acceptance; next-eligible/wait parity and M2 host/hook qualification remain separate | | [Per-Goal Usage, Token, and Cost Surfacing v0](goal-usage-token-cost-v0.md) | S7/S5 | Accepted; Codex aggregate/cost display slice exists | P0 observation→P1 provider coverage: unknown is not zero, deduplicate accounting, price source/freshness; usage grants no budget | | [Intelligent Review and Dynamic Presentation Surfaces v0](intelligent-review-presentation-surfaces-v0.md) | S5 | Accepted; action/attention verticals and local delivery-chain/acceptance review implemented | P1: cross-channel disclosure and governed amendment/settlement review; local visibility does not qualify G2 | | [Human Attention Wishlist v0](human-attention-wishlist-v0.md) | S5/S11 | Accepted; Held | P3: reopen only on repeated second real need; sidecar cannot alter gates/quota/scheduling | diff --git a/examples/personal-workspace-browser/automation-cadence.mjs b/examples/personal-workspace-browser/automation-cadence.mjs index f768c1c56c..8ff34b0535 100644 --- a/examples/personal-workspace-browser/automation-cadence.mjs +++ b/examples/personal-workspace-browser/automation-cadence.mjs @@ -1,152 +1,228 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; +import { createInterface } from "node:readline"; -import { outputDir } from "./fixture.mjs"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { outputDir, repoRoot } from "./fixture.mjs"; import { openWorkspacePage } from "./scenario-context.mjs"; +// The packaged UI writes to the actual HTTP handler and typed file authority. +// Only the surrounding workspace directory remains the shared browser fixture. +async function startCadenceAuthority() { + const root = await mkdtemp(resolve(tmpdir(), "loopx-cadence-browser-")); + await writeFile(resolve(root, "registry.json"), JSON.stringify({ + schema_version: "0.1", goals: [{ id: "multi-agent-projection" }], + })); + const child = spawn(resolveTestPython(), ["-u", "-c", ` +from pathlib import Path +import sys +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) +server.registry_path = Path(sys.argv[1]) / "registry.json" +server.runtime_root = Path(sys.argv[1]) / "runtime" +server.verbose = False +print(server.server_address[1], flush=True) +server.serve_forever() +`, root], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }); + let diagnostic = ""; + child.stderr.on("data", (chunk) => { diagnostic = (diagnostic + chunk).slice(-2000); }); + const lines = createInterface({ input: child.stdout }); + try { + const port = await new Promise((accept, reject) => { + const timeout = setTimeout(() => reject(new Error(`Cadence authority startup timed out: ${diagnostic}`)), 15_000); + child.once("error", (error) => { clearTimeout(timeout); reject(error); }); + child.once("exit", () => { clearTimeout(timeout); reject(new Error(`Cadence authority exited: ${diagnostic}`)); }); + lines.once("line", (line) => { clearTimeout(timeout); accept(Number(line)); }); + }); + if (!Number.isSafeInteger(port) || port < 1) throw new Error("Invalid authority port"); + return { root, url: `http://127.0.0.1:${port}`, async close() { + lines.close(); + if (child.exitCode === null && child.signalCode === null) { + const exited = new Promise((accept) => child.once("exit", accept)); + child.kill("SIGTERM"); + await exited; + } + await rm(root, { recursive: true, force: true }); + } }; + } catch (error) { + lines.close(); child.kill("SIGTERM"); + await rm(root, { recursive: true, force: true }); + throw error; + } +} + export const automationCadenceScenario = { id: "automation-cadence", async run({ browser, collectCoverage, url }) { - let revision = 0; - let rules = []; - const context = await openWorkspacePage(browser, url, { - collectCoverage, - beforeGoto: async (_api, page) => { - await page.route("**/api/chat/automation-cadence**", async (route) => { - const request = route.request(); - const parsed = new URL(request.url()); - const body = request.method() === "POST" ? request.postDataJSON() : null; - const goalId = body?.goal_id ?? parsed.searchParams.get("goal_id"); - const agentId = body?.agent_id ?? parsed.searchParams.get("agent_id") ?? null; - const automationId = body?.automation_id ?? parsed.searchParams.get("automation_id") ?? null; - const applicable = (rows) => rows.filter((rule) => rule.goal_id === goalId && (rule.agent_id === null - || (rule.agent_id === agentId && (rule.automation_id === null || rule.automation_id === automationId)))); - if (body && body.expected_revision !== revision) { - await route.fulfill({ status: 409, json: { error: "configuration revision conflict" } }); - return; - } - const prior = rules.find((rule) => rule.goal_id === goalId && rule.agent_id === agentId && rule.automation_id === automationId); - if (body && prior && body.min_interval_minutes < prior.min_interval_minutes && !body.approve_reduction) { - await route.fulfill({ status: 400, json: { error: "reduction requires explicit owner approval" } }); - return; - } - if (body) { - const changed = [...rules.filter((rule) => rule !== prior), { - goal_id: goalId, agent_id: agentId, automation_id: automationId, min_interval_minutes: body.min_interval_minutes, - }]; - if (parsed.pathname.endsWith("/apply")) { - rules = changed; - revision += 1; - } - const sources = applicable(changed); - await route.fulfill({ status: 200, json: { - ok: true, schema_version: "chat_automation_cadence_v0", goal_id: goalId, - agent_id: agentId, automation_id: automationId, - configuration_revision: revision + (parsed.pathname.endsWith("/preview") ? 1 : 0), - min_interval_minutes: Math.max(0, ...sources.map((rule) => rule.min_interval_minutes)), - enabled: sources.some((rule) => rule.min_interval_minutes > 0), - enforcement: "scheduler_recommendation", pre_model_admission: "not_qualified", - sources, preview_revision: "fixture-preview", written: parsed.pathname.endsWith("/apply"), - readback_verified: parsed.pathname.endsWith("/apply"), - } }); - return; - } - const sources = applicable(rules); - await route.fulfill({ status: 200, json: { - ok: true, schema_version: "chat_automation_cadence_v0", goal_id: goalId, - agent_id: agentId, automation_id: automationId, configuration_revision: revision, - min_interval_minutes: Math.max(0, ...sources.map((rule) => rule.min_interval_minutes)), - enabled: sources.some((rule) => rule.min_interval_minutes > 0), - enforcement: "scheduler_recommendation", pre_model_admission: "not_qualified", sources, - } }); - }); - }, - }); - const { page, close, checkpointCoverage, errors } = context; + const authority = await startCadenceAuthority(); + const path = "/api/chat/automation-cadence"; + let applyCount = 0; + let concurrentChange = false; + let unverifiedReadback = false; + const changes = []; + async function read(agentId = null) { + const query = new URLSearchParams({ goal_id: "multi-agent-projection" }); + if (agentId) query.set("agent_id", agentId); + const response = await fetch(`${authority.url}${path}?${query}`); + if (!response.ok) throw new Error(`Authority read failed: ${response.status}`); + return response.json(); + } + async function interleaveGoalChange() { + const current = await read(); + const change = { goal_id: "multi-agent-projection", agent_id: null, automation_id: null, + min_interval_minutes: 90, expected_revision: current.configuration_revision, + owner_reference: "Synthetic concurrent settings save", approve_reduction: false }; + const preview = await fetch(`${authority.url}${path}/preview`, { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(change) }); + if (!preview.ok) throw new Error("Concurrent policy preview failed"); + const { preview_revision } = await preview.json(); + const applied = await fetch(`${authority.url}${path}/apply`, { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...change, preview_revision }) }); + if (!applied.ok) throw new Error("Concurrent policy apply failed"); + } + let context; try { + context = await openWorkspacePage(browser, url, { + collectCoverage, + beforeGoto: async (_api, page) => { + await page.route("**/api/chat/automation-cadence**", async (route) => { + const parsed = new URL(route.request().url()); + if (parsed.pathname.endsWith("/preview") && concurrentChange) { + concurrentChange = false; + await interleaveGoalChange(); + } + const response = await route.fetch({ url: authority.url + parsed.pathname + parsed.search }); + if (parsed.pathname.endsWith("/apply") && response.ok()) { + applyCount += 1; + changes.push(route.request().postDataJSON()); + if (unverifiedReadback) { + unverifiedReadback = false; + // Exercise an unverified response after a real committed write. + await route.fulfill({ response, json: { ...await response.json(), readback_verified: false } }); + return; + } + } + await route.fulfill({ response }); + }); + }, + }); + const { page, checkpointCoverage, errors } = context; await page.locator(".personal-goal-link", { hasText: "Multi Agent Projection" }).click(); await page.getByRole("button", { name: "Goal 设置", exact: true }).click(); - const target = page.locator(".personal-settings-goal-target"); - await page.getByRole("combobox", { name: "目标 Goal", exact: true }).waitFor(); - await page.getByRole("button", { name: "能力中心" }).click(); - await page.getByRole("radio", { name: "此设备默认", exact: true }).check(); - if (await target.count()) throw new Error("Machine settings retained a Goal-specific target"); - await page.getByRole("button", { name: "自动执行间隔" }).click(); - await target.getByText("Multi Agent Projection", { exact: true }).waitFor(); + await page.getByRole("button", { name: "自动执行间隔", exact: true }).click(); + await page.locator(".personal-settings-goal-target").getByText("Multi Agent Projection", { exact: true }).waitFor(); const panel = page.getByRole("region", { name: "自动执行间隔" }); - const agent = panel.getByRole("combobox", { name: "Agent", exact: true }); - if (await agent.inputValue() !== "") throw new Error("Cadence silently selected an Agent"); - const candidates = await agent.locator("option").evaluateAll((options) => options.map((option) => option.value)); + const target = panel.getByLabel("修改对象", { exact: true }); + const minutes = panel.getByLabel("最短间隔(分钟)", { exact: true }); + const save = panel.getByRole("button", { name: /^保存(?:并降低下限)?$/ }); + const source = (own, parent) => panel.getByText(`本层设置 ${own} 分钟;上层下限 ${parent} 分钟。`, { exact: true }); + if (await target.inputValue() !== "") throw new Error("Cadence silently selected a target"); for (const id of ["codex-latest-lane", "codex-older-lane"]) { - if (!candidates.includes(id)) throw new Error(`Missing Agent candidate: ${id}`); - } - await panel.getByRole("radio", { name: "整个 Goal" }).check(); - await panel.getByText("0 分钟", { exact: true }).first().waitFor(); - if (!await panel.getByText("App 定时触发到启动前钩子的拦截尚未验证。", { exact: false }).count()) { - throw new Error("Cadence settings overstated App enforcement"); + if (!await target.locator(`option[value="agent:${id}"]`).count()) throw new Error(`Missing Agent: ${id}`); } - await panel.getByLabel("最短间隔(分钟)").fill("60"); - await panel.getByLabel("所有者指令或原因").fill("Owner requested hourly automatic runs"); - await panel.getByRole("button", { name: "预览变更", exact: true }).click(); - await panel.getByText("变更后生效下限:60 分钟").waitFor(); - if (revision !== 0) throw new Error("Preview wrote the policy"); - await panel.getByLabel("最短间隔(分钟)").fill("61"); - if (await panel.getByRole("button", { name: "应用已预览变更" }).isEnabled()) { - throw new Error("Editing the policy retained a stale preview"); + await target.selectOption("goal"); + await minutes.waitFor(); + await minutes.fill("60"); + if (!await save.isEnabled()) throw new Error("A blank optional note blocked Save"); + // Two submits in one event boundary must still commit only once. + await panel.locator("form").evaluate((form) => { form.requestSubmit(); form.requestSubmit(); }); + await source(60, 0).waitFor(); + await panel.getByText("已保存并核对生效值。", { exact: true }).waitFor(); + if (applyCount !== 1 || changes[0].approve_reduction || !changes[0].owner_reference.startsWith("App settings Save:")) { + throw new Error("Save duplicated the write or lost explicit audit provenance"); } - await panel.getByLabel("最短间隔(分钟)").fill("60"); - await panel.getByRole("button", { name: "预览变更", exact: true }).click(); - await page.screenshot({ path: resolve(outputDir, "desktop-automation-cadence-preview.png"), fullPage: false, animations: "disabled" }); - await panel.getByRole("button", { name: "应用已预览变更" }).click(); - await panel.getByText("已保存并从 quota 策略读回。").waitFor(); - if (revision !== 1) throw new Error("Apply did not write the reviewed policy"); - await panel.getByLabel("最短间隔(分钟)").fill("30"); - await panel.getByLabel("所有者指令或原因").fill("Owner explicitly lowers cadence"); - if (await panel.getByRole("button", { name: "预览变更", exact: true }).isEnabled()) { - throw new Error("Reducing a floor did not require explicit owner intent"); + await minutes.fill("-1"); + await panel.getByText("请输入 0 到 525600 之间的整数分钟。", { exact: true }).waitFor(); + if (await save.isEnabled()) throw new Error("Invalid interval was writable"); + await minutes.fill("30"); + await save.click(); + await source(30, 0).waitFor(); + if (!changes.at(-1).approve_reduction) throw new Error("Explicit lowering Save lost reduction intent"); + await minutes.fill("60"); await save.click(); await source(60, 0).waitFor(); + await target.selectOption("agent:codex-latest-lane"); + await panel.getByText("继承上层 60 分钟;本层未设置。", { exact: true }).waitFor(); + await minutes.fill("120"); + await target.selectOption("agent:codex-older-lane"); + await panel.getByText("继承上层 60 分钟;本层未设置。", { exact: true }).waitFor(); + if (await minutes.inputValue() !== "0" || await save.isEnabled()) throw new Error("Another Agent retained a writable draft"); + await target.selectOption("agent:codex-latest-lane"); + await panel.getByText("继承上层 60 分钟;本层未设置。", { exact: true }).waitFor(); + await minutes.fill("120"); await save.click(); await source(120, 60).waitFor(); + await minutes.fill("10"); + await panel.getByText("上层的 60 分钟下限仍然生效。", { exact: true }).waitFor(); + await page.screenshot({ path: resolve(outputDir, "desktop-automation-cadence-inherited.png"), animations: "disabled" }); + await save.click(); await source(10, 60).waitFor(); + if ((await read("codex-older-lane")).min_interval_minutes !== 60 || (await read()).min_interval_minutes !== 60) { + throw new Error("Agent save changed a peer or Goal default"); } - await panel.getByRole("checkbox", { name: "我明确要降低或移除此层现有的间隔下限。" }).check(); - if (!await panel.getByRole("button", { name: "预览变更", exact: true }).isEnabled()) { - throw new Error("Explicit reduction remained unavailable"); + concurrentChange = true; + await minutes.fill("20"); await save.click(); + await panel.getByRole("alert").waitFor(); + if ((await read("codex-latest-lane")).sources.find((row) => row.agent_id === "codex-latest-lane").min_interval_minutes !== 10) { + throw new Error("A stale Save overwrote the current policy"); } - await panel.getByRole("radio", { name: "单个 Agent" }).check(); - await agent.selectOption("codex-latest-lane"); - await panel.getByText("继承上层 60 分钟;本层未设置。").waitFor(); - await panel.getByText("60 分钟", { exact: true }).first().waitFor(); - await panel.getByLabel("最短间隔(分钟)").fill("120"); - await panel.getByLabel("所有者指令或原因").fill("Owner requested two-hour minimum for this Agent"); - await panel.getByRole("button", { name: "预览变更", exact: true }).click(); - await panel.getByText("变更后生效下限:120 分钟").waitFor(); - await agent.selectOption("codex-older-lane"); - await panel.getByText("继承上层 60 分钟;本层未设置。").waitFor(); - if (await panel.getByRole("button", { name: "应用已预览变更" }).isEnabled()) { - throw new Error("Changing Agent retained the previous Agent's preview"); + if (await save.isEnabled()) throw new Error("Stale policy could be resubmitted without refresh"); + await panel.getByRole("button", { name: "刷新策略", exact: true }).click(); + await source(10, 90).waitFor(); + unverifiedReadback = true; + await minutes.fill("20"); await save.click(); await source(20, 90).waitFor(); + await panel.getByText("已写入,但最新生效值尚未核实;请刷新后再修改。", { exact: true }).waitFor(); + if (await panel.getByText("已保存并核对生效值。", { exact: true }).count()) throw new Error("Unverified readback looked like verified success"); + await panel.getByRole("button", { name: "刷新策略", exact: true }).click(); await source(20, 90).waitFor(); + await target.selectOption("goal"); await source(90, 0).waitFor(); + await minutes.fill("0"); await save.click(); await source(0, 0).waitFor(); + await target.selectOption("agent:codex-latest-lane"); await source(20, 0).waitFor(); + await minutes.fill("10"); await minutes.press("Enter"); await source(10, 0).waitFor(); + if ((await read("codex-latest-lane")).min_interval_minutes !== 10 || (await read("codex-older-lane")).min_interval_minutes !== 0) { + throw new Error("The 10-minute setting did not survive a fresh authority read"); } - await agent.selectOption("codex-latest-lane"); - await panel.getByText("继承上层 60 分钟;本层未设置。").waitFor(); - await panel.getByLabel("最短间隔(分钟)").fill("120"); - await panel.getByLabel("所有者指令或原因").fill("Owner requested two-hour minimum for this Agent"); - await panel.getByRole("button", { name: "预览变更", exact: true }).click(); - await panel.getByText("变更后生效下限:120 分钟").waitFor(); - await panel.getByRole("button", { name: "应用已预览变更" }).click(); - await panel.getByText("本层设置 120 分钟;上层下限 60 分钟。").waitFor(); + await panel.getByText("限定单个自动化", { exact: true }).click(); + await panel.getByRole("checkbox", { name: "仅对这个自动化生效", exact: true }).check(); + await panel.getByLabel("自动化 ID", { exact: true }).fill("daily"); + await panel.getByText("继承上层 10 分钟;本层未设置。", { exact: true }).waitFor(); + await panel.getByText("添加备注(可选)", { exact: true }).click(); + await panel.getByLabel("备注", { exact: true }).fill("Synthetic optional note ".repeat(6)); + await minutes.fill("20"); await save.click(); await source(20, 10).waitFor(); + if ((await read("codex-latest-lane")).min_interval_minutes !== 10 + || !changes.at(-1).owner_reference.includes("Synthetic optional note") + || changes.at(-1).owner_reference.length > 256) throw new Error("Automation targeting or optional note changed the Agent policy"); + await panel.getByLabel("自动化 ID", { exact: true }).fill("hourly"); + await panel.getByText("继承上层 10 分钟;本层未设置。", { exact: true }).waitFor(); + if (await minutes.inputValue() !== "0" || await save.isEnabled()) throw new Error("Changing automation retained a writable draft"); + await panel.getByRole("checkbox", { name: "仅对这个自动化生效", exact: true }).uncheck(); await source(10, 0).waitFor(); + await panel.getByText("限定单个自动化", { exact: true }).click(); + const noteDetails = panel.locator("details[open]").filter({ hasText: "添加备注(可选)" }); + if (await noteDetails.count()) await noteDetails.locator("summary").click(); + const files = await readdir(resolve(authority.root, "runtime"), { recursive: true }); + const policyPath = files.find((file) => file.endsWith(".json")); + const stored = JSON.parse(await readFile(resolve(authority.root, "runtime", policyPath), "utf8")); + if (!stored.rules.every((rule) => rule.owner_reference.startsWith("App settings Save:"))) throw new Error("The real policy lost Save audit references"); await page.screenshot({ path: resolve(outputDir, "desktop-automation-cadence-agent.png"), animations: "disabled" }); - await agent.selectOption("codex-older-lane"); - await panel.getByText("继承上层 60 分钟;本层未设置。").waitFor(); - await agent.selectOption("codex-latest-lane"); - await panel.getByText("本层设置 120 分钟;上层下限 60 分钟。").waitFor(); - if (revision !== 2 || rules.filter((rule) => rule.agent_id !== null).length !== 1) { - throw new Error("Agent override changed another scope"); - } await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: resolve(outputDir, "mobile-automation-cadence-inherited.png"), fullPage: false, animations: "disabled" }); - const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1); - if (overflow) throw new Error("Cadence settings overflowed the mobile viewport"); - if (errors.length) throw new Error(`Browser errors: ${errors.join(" | ")}`); + if (await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1)) throw new Error("Cadence settings overflowed the mobile viewport"); + await page.setViewportSize({ width: 1512, height: 982 }); + await page.getByRole("button", { name: "语言", exact: true }).click(); + await page.getByRole("radio", { name: "English", exact: true }).click(); + await page.getByRole("button", { name: "Automatic execution interval", exact: true }).click(); + await page.getByLabel("Applies to", { exact: true }).selectOption("agent:codex-latest-lane"); + await page.getByText("This scope: 10 min · inherited minimum: 0 min.", { exact: true }).waitFor(); + await page.getByLabel("Minimum interval (minutes)", { exact: true }).fill("20"); + if (!await page.getByRole("button", { name: "Save", exact: true }).isEnabled()) throw new Error("English Save required a note"); + await page.screenshot({ path: resolve(outputDir, "desktop-automation-cadence-english.png"), animations: "disabled" }); + const unexpected = errors.filter((error) => !error.includes("409 (Conflict)")); + if (unexpected.length) throw new Error(`Browser errors: ${unexpected.join(" | ")}`); await checkpointCoverage(); - return { coverageEntries: await close(), note: "Explicit Agent selection, complete candidates, scope-isolated 120-minute override, stale preview invalidation, inheritance, App boundary and desktop/mobile layouts verified." }; + return { coverageEntries: await context.close(), note: "Packaged Save → real HTTP/TS file authority: optional notes, one commit on duplicate submit, explicit reduction, inheritance and peer isolation, stale CAS rejection, unverified-readback recovery, keyboard and mobile verified. Codex timers remain separately configured." }; } catch (error) { - await close(); + if (context) { + await context.page.screenshot({ path: resolve(outputDir, "automation-cadence-failure.png"), animations: "disabled" }).catch(() => {}); + console.error((await context.page.locator(".personal-settings-main").innerText().catch(() => "")).slice(0, 2000)); + } + await context?.close(); throw error; - } + } finally { await authority.close(); } }, };