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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ type Scope = "goal" | "agent" | "automation";

export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGoal }>) {
const { t } = useWorkspaceI18n();
const agentLanes = useMemo(() => goal.agentLanes?.length
? goal.agentLanes : goal.agentId ? [{ agentId: goal.agentId, label: goal.agentLabel ?? goal.agentId }] : [], [goal]);
const [scope, setScope] = useState<Scope>("goal");
const [agentId, setAgentId] = useState(agentLanes[0]?.agentId ?? "");
const agentLanes = useMemo(() => goal.agentLanes ?? (goal.agentId ? [{ agentId: goal.agentId, label: goal.agentLabel ?? goal.agentId }] : []), [goal]);
const [scope, setScope] = useState<Scope>("agent");
const [agentId, setAgentId] = useState("");
const [automationId, setAutomationId] = useState("");
const [inspection, setInspection] = useState<AutomationCadence | null>(null);
const [minutes, setMinutes] = useState("0");
Expand All @@ -28,14 +27,10 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo
const [reloadSequence, setReloadSequence] = useState(0);
const scopedAgent = scope === "goal" ? null : agentId.trim() || null;
const scopedAutomation = scope === "automation" ? automationId.trim() || null : null;
const scopeReady = scope === "goal" || Boolean(scopedAgent && (scope !== "automation" || scopedAutomation));
const scopeReady = scope === "goal" || Boolean(scopedAgent && agentLanes.some((lane) => lane.agentId === scopedAgent) && (scope !== "automation" || scopedAutomation));

useEffect(() => {
setAgentId(agentLanes[0]?.agentId ?? "");
}, [goal.goalId]);

useEffect(() => {
if (!scopeReady) { setInspection(null); setPreview(null); return; }
if (!scopeReady) { setInspection(null); setPreview(null); setBusy(null); return; }
let active = true;
setBusy("load");
setError(null);
Expand All @@ -53,7 +48,7 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo
.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, reloadSequence, t]);
}, [goal.goalId, scope, 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) ?? []));
Expand Down Expand Up @@ -109,8 +104,10 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo
<label key={option}><input checked={scope === option} disabled={Boolean(busy)} onChange={() => { setScope(option); invalidate(); }} type="radio" name="cadence-scope" value={option} />{t(`cadence.scope.${option}`)}</label>)}
</fieldset>
{scope !== "goal" ? <div className="personal-cadence-fields">
<label>{t("cadence.agentId")}<input disabled={Boolean(busy)} list="cadence-agent-lanes" onChange={(event) => { setAgentId(event.target.value); invalidate(); }} value={agentId} /></label>
<datalist id="cadence-agent-lanes">{agentLanes.map((lane) => <option key={lane.agentId} value={lane.agentId}>{lane.label}</option>)}</datalist>
<label>{t("cadence.agentId")}<select aria-label={t("cadence.agentId")} aria-describedby="cadence-agent-hint" disabled={Boolean(busy) || agentLanes.length === 0} onChange={(event) => { setAgentId(event.target.value); invalidate(); }} value={agentId}>
<option value="">{t(agentLanes.length ? "cadence.chooseAgent" : "cadence.noAgents")}</option>
{agentLanes.map((lane) => <option key={lane.agentId} value={lane.agentId}>{lane.label === lane.agentId ? lane.agentId : `${lane.label} · ${lane.agentId}`}</option>)}
</select><small id="cadence-agent-hint">{t("cadence.agentHint")}</small></label>
{scope === "automation" ? <label>{t("cadence.automationId")}<input disabled={Boolean(busy)} onChange={(event) => { setAutomationId(event.target.value); invalidate(); }} value={automationId} /></label> : null}
</div> : null}
{busy === "load" ? <p aria-live="polite">{t("common.loading")}</p> : null}
Expand All @@ -127,7 +124,7 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo
<label>{t("cadence.ownerReference")}<input disabled={Boolean(busy)} maxLength={256} onChange={(event) => { setOwnerReference(event.target.value); invalidate(); }} value={ownerReference} /><small>{t("cadence.referenceHint")}</small></label>
</div>
{reduction ? <label className="personal-cadence-reduction"><input checked={approveReduction} disabled={Boolean(busy)} onChange={(event) => { setApproveReduction(event.target.checked); invalidate(); }} type="checkbox" />{t("cadence.reduction")}</label> : null}
{preview ? <div className="personal-cadence-preview"><strong>{t("cadence.preview")}</strong><span>{t("cadence.previewValue", { minutes: preview.min_interval_minutes })}</span><small>{t("cadence.previewLocked")}</small></div> : null}
{preview ? <div className="personal-cadence-preview"><strong>{t("cadence.preview")}</strong><small>{goal.title} · {scopedAgent ?? t("cadence.scope.goal")}{scopedAutomation ? ` · ${scopedAutomation}` : ""}</small><span>{t("cadence.previewValue", { minutes: preview.min_interval_minutes })}</span><small>{t("cadence.previewLocked")}</small></div> : null}
<div className="personal-cadence-actions"><button disabled={!canPreview} onClick={() => void createPreview()} type="button">{t("cadence.preview")}</button><button className="is-primary" disabled={!preview || Boolean(busy)} onClick={() => void apply()} type="button">{t("cadence.apply")}</button></div>
</> : null}
{error ? <p className="personal-machine-error" role="alert">{error} <button onClick={() => { invalidate(); setReloadSequence((value) => value + 1); }} type="button"><RefreshCw aria-hidden size={14} />{t("cadence.retry")}</button></p> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,9 @@ const en = {
"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.automationId": "Automation ID",
"cadence.effective": "Effective minimum",
"cadence.unconfigured": "No minimum configured yet.",
Expand Down Expand Up @@ -2146,6 +2149,9 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"cadence.scope.agent": "单个 Agent",
"cadence.scope.automation": "单个自动化",
"cadence.agentId": "Agent",
"cadence.chooseAgent": "请选择 Agent",
"cadence.noAgents": "此 Goal 暂无已注册 Agent",
"cadence.agentHint": "从此 Goal 的 Agent 中选择;下限仅针对所选 Agent 生效。",
"cadence.automationId": "自动化 ID",
"cadence.effective": "生效下限",
"cadence.unconfigured": "尚未设置间隔下限。",
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export function WorkspaceSettingsPage({
onChanged={onChanged}
/>
) : null}
{tab === "cadence" && selectedGoal ? <AutomationCadenceSettings goal={selectedGoal} /> : null}
{tab === "cadence" && selectedGoal ? <AutomationCadenceSettings key={selectedGoal.goalId} goal={selectedGoal} /> : null}

{tab === "appearance" ? (
<section className="personal-detail-card personal-appearance-settings">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,11 @@ 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. It does
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
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ fail-closed,显式手动理由也无法绕过。缺少阶段字段的旧记录

M3 设置页阶段成果复用 quota 权威,提供 Goal/Agent/Automation 作用域的修订号锁定
预览、应用与读回,并把过期的配置意图作为 typed conflict 报出,而不是解析错误文案。
默认从 Goal 投影的 Agent 列表中明确选择目标,不预选任意 Agent,也不按当前 ID 过滤候选。
切换目标会清除旧预览;预览展示 Goal 和 Agent。单个 Agent 的下限不会修改其他 Agent 或 Goal 默认值。
它不修改已有 Codex App 定时器;下次可运行时间及 Lark/CLI 等待反馈一致性仍未完成。
App 定时器到 hook、非 Turn launcher 及真实模型宿主推广仍未验收,M4 仍是设计选项。
本提案不激活、不改绑任何已有自动化;测试和 PR 必须区分确定性验证与宿主推广。
Expand Down
49 changes: 41 additions & 8 deletions examples/personal-workspace-browser/automation-cadence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,20 @@ export const automationCadenceScenario = {
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.agent_id === null
|| (rule.agent_id === agentId && (rule.automation_id === null || rule.automation_id === automationId)));
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.agent_id === agentId && rule.automation_id === automationId);
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), {
agent_id: agentId, automation_id: automationId, min_interval_minutes: body.min_interval_minutes,
goal_id: goalId, agent_id: agentId, automation_id: automationId, min_interval_minutes: body.min_interval_minutes,
}];
if (parsed.pathname.endsWith("/apply")) {
rules = changed;
Expand Down Expand Up @@ -63,15 +63,22 @@ export const automationCadenceScenario = {
});
const { page, close, checkpointCoverage, errors } = context;
try {
await page.locator(".personal-goal-link", { hasText: "Product Release" }).click();
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 target.getByText("Product Release", { exact: true }).waitFor();
await target.getByText("Multi Agent Projection", { exact: true }).waitFor();
await page.getByRole("button", { name: "能力中心" }).click();
if (await target.count()) throw new Error("Machine settings retained a Goal-specific target");
await page.getByRole("button", { name: "自动执行间隔" }).click();
await target.getByText("Product Release", { exact: true }).waitFor();
await 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));
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");
Expand Down Expand Up @@ -101,15 +108,41 @@ export const automationCadenceScenario = {
throw new Error("Explicit reduction remained unavailable");
}
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");
}
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 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(" | ")}`);
await checkpointCoverage();
return { coverageEntries: await close(), note: "Goal policy preview, apply/readback, Agent inheritance, App boundary and desktop/mobile layouts verified." };
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." };
} catch (error) {
await close();
throw error;
Expand Down
30 changes: 30 additions & 0 deletions tests/test_chat_automation_cadence_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,36 @@ def test_chat_cadence_preview_apply_inheritance_and_stale_rejection(
port, "POST", f"{path}/preview", {**lower, "approve_reduction": True}
)
assert status == 200 and approved["min_interval_minutes"] == 30

# A per-Agent setting must not become a Goal default or affect a peer.
agent_change = {
**change,
"agent_id": "agent-a",
"min_interval_minutes": 120,
"expected_revision": applied["configuration_revision"],
}
status, agent_preview = _exchange(port, "POST", f"{path}/preview", agent_change)
assert status == 200 and agent_preview["min_interval_minutes"] == 120
status, agent_applied = _exchange(
port,
"POST",
f"{path}/apply",
{
**agent_change,
"preview_revision": agent_preview["preview_revision"],
},
)
assert status == 200 and agent_applied["readback_verified"] is True
for agent_id, expected in (("agent-a", 120), ("agent-b", 60)):
agent_query = urlencode({"goal_id": "goal-one", "agent_id": agent_id})
status, observed = _exchange(port, "GET", f"{path}?{agent_query}")
assert status == 200 and observed["min_interval_minutes"] == expected
assert (
_exchange(port, "GET", f"{path}?goal_id=goal-one")[1][
"min_interval_minutes"
]
== 60
)
finally:
server.shutdown()
thread.join(timeout=5)
Expand Down
Loading