diff --git a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts index 118a818d42..ceedb155dc 100644 --- a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts +++ b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts @@ -1,6 +1,22 @@ import assert from "node:assert/strict"; -import { changeStewardExecutorDraft, goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js"; +import { changeStewardExecutorDraft, goalExploreEditorValue, goalExploreWriteValue, goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js"; + +const exploreEditor = {fields: [{key: "mode"}, {key: "profile"}, {key: "composition_mode"}, {key: "composition_scope_id", nullable: true}]}; +const exploreEffective = {mode: "planning", profile: "generic", composition_mode: "disabled", composition_scope_id: null}; +for (const current of [{profile: "adaptive-resilient"}, {composition_mode: "explicit_only", composition_scope_id: "bounded-scope"}]) { + const draft = projectEditableCapabilityConfiguration(exploreEditor, goalExploreEditorValue(current, exploreEffective)); + assert.equal(draft.mode, "planning", "sparse Goal overrides display the live inherited mode"); + assert.equal(Object.hasOwn(goalExploreWriteValue({...draft, profile: "generic"}, false), "mode"), false, + "saving another field must not pin the inherited mode"); + assert.equal(goalExploreWriteValue({...draft, mode: "off"}, true).mode, "off", "an explicit off choice remains writable"); + const json = parseEditableCapabilityJson(exploreEditor, JSON.stringify(goalExploreWriteValue(draft, false)))!; + assert.equal(Object.hasOwn(json, "mode"), false, "form to JSON preserves inheritance intent"); + assert.equal(projectEditableCapabilityConfiguration(exploreEditor, json, exploreEffective).mode, "planning", + "sparse JSON returning to the form displays the effective mode"); +} +assert.equal(goalExploreEditorValue({mode: "evidence"}, exploreEffective).mode, "evidence", + "stored explicit modes win even when device preferences change"); const codexDraft = { executor_endpoint: "codex", executor_model: "gpt-fixture", executor_reasoning_effort: "high", selection_policy: "preferred" }; const managedDraft = changeStewardExecutorDraft(codexDraft, "executor_endpoint", "dsh"); diff --git a/apps/presentation/dashboard/src/data/capability-configuration.ts b/apps/presentation/dashboard/src/data/capability-configuration.ts index a1640026e6..c87d8431bb 100644 --- a/apps/presentation/dashboard/src/data/capability-configuration.ts +++ b/apps/presentation/dashboard/src/data/capability-configuration.ts @@ -112,6 +112,18 @@ export function goalReplanCadenceWriteValue( return value; } +/** Sparse Goal settings display live inherited fields without pinning them. */ +export function goalExploreEditorValue(current: unknown, effective: unknown): Record { + return {...configurationObject(effective), ...configurationObject(current)}; +} + +/** Only a stored or deliberately edited mode becomes a Goal override. */ +export function goalExploreWriteValue(draft: Record, modeOverride: boolean): Record { + const value = {...draft}; + if (!modeOverride) delete value.mode; + return value; +} + /** A v0 storage selector did not enable creation; opening Settings must not. */ export function goalStorageEditorValue(value: unknown): unknown { const record = configurationObject(value); 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 883185b71e..fc0f10d6fd 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 @@ -11,7 +11,7 @@ import { type GoalConfigurationPreview, type GoalConfigurationPartialWrite, } from "../../data/chat"; -import { goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../../data/capability-configuration"; +import { goalExploreEditorValue, goalExploreWriteValue, goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../../data/capability-configuration"; import { useWorkspaceI18n } from "./i18n"; import { CapabilityConfigurationFields } from "./capability-configuration-fields"; import { withReportScheduleTimezone } from "./periodic-report-schedule-field"; @@ -39,16 +39,18 @@ type CapabilityMutationState = Readonly<{ draft: Record; cadenceOverride: boolean; horizonOverride: boolean; + exploreModeOverride: boolean; partialWrite: GoalConfigurationPartialWrite | null; preview: GoalConfigurationPreview | null; }>; -function goalWriteDraft(selected: CapabilityConfigurationCatalog["capabilities"][number], draft: Record, editorMode: "guided" | "json", cadenceOverride: boolean, horizonOverride: boolean) { +function goalWriteDraft(selected: CapabilityConfigurationCatalog["capabilities"][number], draft: Record, editorMode: "guided" | "json", cadenceOverride: boolean, horizonOverride: boolean, exploreModeOverride: boolean) { const defaults = selected.capability_id === "todo_replan_cadence" ? editorMode === "json" ? undefined : replanCadenceEditorDefaults(selected.effective_configuration?.configuration, selected.default) : selected.default; const writable = projectEditableCapabilityConfiguration(selected.configuration_editor, draft, defaults); if (selected.capability_id === "reward_memory") return rewardMemoryWriteValue(writable); + if (selected.capability_id === "explore_harness") return goalExploreWriteValue(writable, exploreModeOverride); if (selected.capability_id === "todo_replan_cadence" && editorMode === "guided") { return goalReplanCadenceWriteValue(writable, cadenceOverride, horizonOverride); } @@ -79,6 +81,7 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read draft: {}, cadenceOverride: false, horizonOverride: false, + exploreModeOverride: false, partialWrite: null, preview: null, }); @@ -105,6 +108,8 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read selected?.configuration_editor ?? { fields: [] }, selected?.capability_id === "todo_replan_cadence" ? goalReplanCadenceEditorValue(selected.current, selected.effective_configuration?.configuration) + : selected?.capability_id === "explore_harness" + ? goalExploreEditorValue(selected.current, selected.effective_configuration?.configuration) : selected?.capability_id === "reward_memory" ? rewardMemoryScopeDraft(selected.current, memoryScope.current.goalId === goalId ? memoryScope.current.agentId : "") : current, @@ -112,6 +117,7 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read ), cadenceOverride: hasReplanCadenceOverride(selected?.current), horizonOverride: Object.hasOwn(selected?.current ?? {}, "planning_horizon_goal"), + exploreModeOverride: Object.hasOwn(selected?.current ?? {}, "mode"), partialWrite: null, preview: null, }); @@ -123,7 +129,7 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read setMutation((current) => ({ ...current, busy: "preview", partialWrite: null })); setError(null); try { - const nextPreview = await previewGoalConfiguration(goalId, selected.capability_id, configuration === null ? null : goalWriteDraft(selected, configuration, editorMode, mutation.cadenceOverride, mutation.horizonOverride)); + const nextPreview = await previewGoalConfiguration(goalId, selected.capability_id, configuration === null ? null : goalWriteDraft(selected, configuration, editorMode, mutation.cadenceOverride, mutation.horizonOverride, mutation.exploreModeOverride)); setMutation((current) => ({ ...current, preview: nextPreview })); } catch (reason) { setError(reason instanceof Error ? reason.message : t("capabilities.previewFailed")); @@ -137,7 +143,7 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read setMutation((current) => ({ ...current, busy: "apply" })); setError(null); try { - const writableDraft = goalWriteDraft(selected, mutation.draft, editorMode, mutation.cadenceOverride, mutation.horizonOverride); + const writableDraft = goalWriteDraft(selected, mutation.draft, editorMode, mutation.cadenceOverride, mutation.horizonOverride, mutation.exploreModeOverride); const result = await applyGoalConfiguration( goalId, selected.capability_id, @@ -172,6 +178,7 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read : { ...current.draft, [key]: value }, cadenceOverride: current.cadenceOverride || (selected?.capability_id === "todo_replan_cadence" && (key === "count_unit" || key === "count")), horizonOverride: current.horizonOverride || (selected?.capability_id === "todo_replan_cadence" && key === "planning_horizon_goal"), + exploreModeOverride: current.exploreModeOverride || (selected?.capability_id === "explore_harness" && key === "mode"), preview: null, })); setError(null); @@ -186,12 +193,15 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read } setMutation((current) => ({ ...current, preview: null, draft: parsed ? projectEditableCapabilityConfiguration(selected.configuration_editor, parsed, - selected.capability_id === "todo_replan_cadence" ? undefined : selected.default) + selected.capability_id === "todo_replan_cadence" ? undefined + : selected.capability_id === "explore_harness" ? selected.effective_configuration?.configuration : selected.default) : current.draft, cadenceOverride: selected.capability_id === "todo_replan_cadence" && parsed ? hasReplanCadenceOverride(parsed) : current.cadenceOverride, horizonOverride: selected.capability_id === "todo_replan_cadence" && parsed ? Object.hasOwn(parsed, "planning_horizon_goal") : current.horizonOverride, + exploreModeOverride: selected.capability_id === "explore_harness" && parsed + ? Object.hasOwn(parsed, "mode") : current.exploreModeOverride, })); setError(null); } @@ -199,14 +209,17 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read function changeMode() { if (loading || mutation.busy || !jsonValid) return; if (editorMode === "guided" && selected) { - const jsonValue = selected.capability_id === "todo_replan_cadence" - ? goalWriteDraft(selected, mutation.draft, editorMode, mutation.cadenceOverride, mutation.horizonOverride) + const jsonValue = ["todo_replan_cadence", "explore_harness"].includes(selected.capability_id) + ? goalWriteDraft(selected, mutation.draft, editorMode, mutation.cadenceOverride, mutation.horizonOverride, mutation.exploreModeOverride) : mutation.draft; setJsonDraft(JSON.stringify(jsonValue, null, 2)); } setEditorMode(editorMode === "guided" ? "json" : "guided"); setMutation((current) => ({ ...current, preview: null, - draft: editorMode === "json" && selected?.capability_id === "todo_replan_cadence" + draft: editorMode === "json" && selected?.capability_id === "explore_harness" + ? projectEditableCapabilityConfiguration(selected.configuration_editor, + goalExploreEditorValue(current.draft, selected.effective_configuration?.configuration), selected.default) + : editorMode === "json" && selected?.capability_id === "todo_replan_cadence" ? projectEditableCapabilityConfiguration(selected.configuration_editor, current.draft, replanCadenceEditorDefaults(selected.effective_configuration?.configuration, selected.default)) : current.draft, diff --git a/docs/architecture/rfcs/desktop-execution-frontends-v0.md b/docs/architecture/rfcs/desktop-execution-frontends-v0.md index 738ccac9f0..74267fcc19 100644 --- a/docs/architecture/rfcs/desktop-execution-frontends-v0.md +++ b/docs/architecture/rfcs/desktop-execution-frontends-v0.md @@ -111,7 +111,7 @@ editor or storage path happens to exist. | `todo_replan_cadence` | Device default + Goal override | Reuse review frequency while allowing a Goal's work rhythm. This is completed-work review cadence, not the automation timer. | | `multi_subagent` | Goal only | Child model, capacity, responsibility domains and delegation bindings apply to one Goal's team and resource boundary. | | `peer_task_coordination` | Goal only | Coordinator identity must refer to a member of this Goal, not an inherited unrelated Agent. | -| `explore_harness`, `explore_graph` | Goal only | Exploration strategy and opt-in affect this Goal's planning and evidence graph. | +| `explore_harness` | Device mode default + Goal override | Existing and new Goals inherit off/evidence/planning live. Explicit legacy enabled flags or Goal mode override it; profiles, composition scopes and external delivery authority stay Goal-local. | | `progress_review` | Goal only | Drift mode and threshold belong with this Goal's pinned acceptance contract revision. | | `reward_memory` | Goal only | Private provider binding and enabled Agents are Goal-local; a global preference cannot spread that data binding. | | `lark_kanban_heartbeat_sync` | Goal only | The target board and synchronization follow this Goal's binding and audience. | diff --git a/docs/architecture/rfcs/desktop-execution-frontends-v0.zh-CN.md b/docs/architecture/rfcs/desktop-execution-frontends-v0.zh-CN.md index 53d752dea7..9cd1326499 100644 --- a/docs/architecture/rfcs/desktop-execution-frontends-v0.zh-CN.md +++ b/docs/architecture/rfcs/desktop-execution-frontends-v0.zh-CN.md @@ -86,7 +86,7 @@ manager Agent,也不把连接器硬编码到 Codex。 | `todo_replan_cadence` | 设备默认值 + Goal 覆盖 | 复用复盘频率并允许 Goal 工作节奏;这是已完成工作复盘频率,不是 automation timer。 | | `multi_subagent` | 仅 Goal | 子模型、容量、职责域和委托 binding 属于一个 Goal 的团队与资源边界。 | | `peer_task_coordination` | 仅 Goal | coordinator 必须是本 Goal 成员,不能继承无关 Agent 身份。 | -| `explore_harness`、`explore_graph` | 仅 Goal | 探索策略与 opt-in 只影响本 Goal 的规划和证据图。 | +| `explore_harness` | 设备模式默认值 + Goal 覆盖 | 已有与新建 Goal 实时继承 off/evidence/planning。Goal 的显式模式或旧 enabled 标志优先;规划 profile、组合范围和外部交付权限仍属于各 Goal。 | | `progress_review` | 仅 Goal | drift 模式与阈值绑定本 Goal 固定的验收合同版本。 | | `reward_memory` | 仅 Goal | 私有 provider binding 和启用 Agent 是 Goal 局部配置,设备偏好不能扩散数据 binding。 | | `lark_kanban_heartbeat_sync` | 仅 Goal | 目标看板与同步遵循本 Goal 的 binding 和受众。 | diff --git a/docs/assets/explore-machine-defaults/device-desktop.png b/docs/assets/explore-machine-defaults/device-desktop.png new file mode 100644 index 0000000000..18ab96f1d3 Binary files /dev/null and b/docs/assets/explore-machine-defaults/device-desktop.png differ diff --git a/docs/assets/explore-machine-defaults/goal-inheritance-desktop.png b/docs/assets/explore-machine-defaults/goal-inheritance-desktop.png new file mode 100644 index 0000000000..09d2daa30c Binary files /dev/null and b/docs/assets/explore-machine-defaults/goal-inheritance-desktop.png differ diff --git a/docs/assets/explore-machine-defaults/goal-inheritance-narrow.png b/docs/assets/explore-machine-defaults/goal-inheritance-narrow.png new file mode 100644 index 0000000000..b44f44c8e6 Binary files /dev/null and b/docs/assets/explore-machine-defaults/goal-inheritance-narrow.png differ diff --git a/docs/assets/explore-machine-defaults/repair-after-desktop.png b/docs/assets/explore-machine-defaults/repair-after-desktop.png new file mode 100644 index 0000000000..8d77ab6b62 Binary files /dev/null and b/docs/assets/explore-machine-defaults/repair-after-desktop.png differ diff --git a/docs/assets/explore-machine-defaults/repair-after-narrow.png b/docs/assets/explore-machine-defaults/repair-after-narrow.png new file mode 100644 index 0000000000..ba1fae65d5 Binary files /dev/null and b/docs/assets/explore-machine-defaults/repair-after-narrow.png differ diff --git a/docs/assets/explore-machine-defaults/repair-before-desktop.png b/docs/assets/explore-machine-defaults/repair-before-desktop.png new file mode 100644 index 0000000000..24fb312746 Binary files /dev/null and b/docs/assets/explore-machine-defaults/repair-before-desktop.png differ diff --git a/docs/assets/explore-machine-defaults/repair-before-narrow.png b/docs/assets/explore-machine-defaults/repair-before-narrow.png new file mode 100644 index 0000000000..3a438bd866 Binary files /dev/null and b/docs/assets/explore-machine-defaults/repair-before-narrow.png differ diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index 2829fdef58..a2dfed0f62 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -315,8 +315,8 @@ def capability_configuration_editor( "fields": [_field("enabled", "Enabled", "boolean")], }, "explore_harness": { - "supported_scopes": ["goal"], - "writable_scopes": ["goal"], + "supported_scopes": ["machine", "goal"], + "writable_scopes": ["machine", "goal"], "fields": [ _field("mode", "Exploration mode", "select", options=("off", "evidence", "planning"), description="Evidence only, or evidence with planning. Spawn authority is separate."), @@ -573,6 +573,11 @@ def _machine_catalog_entry( explore_harness_profiles=explore_harness_profiles, ), } + if capability_id == "explore_harness": + # Machine preference owns only the mode; profiles and research scope + # stay on each Goal. Reuse the existing mode field and generic editor. + editor = entry["configuration_editor"] + editor["fields"] = [field for field in editor["fields"] if field["key"] == "mode"] if isinstance(namespace.get("current"), Mapping): entry["machine_current"] = deepcopy(namespace["current"]) if isinstance(namespace.get("configuration_template"), Mapping): @@ -610,10 +615,13 @@ def _merge_goal_feature( for field in ("default", "current", "documentation", "context_contribution"): if isinstance(feature.get(field), Mapping): entry[field] = deepcopy(feature[field]) + previous_editor = entry.get("configuration_editor") entry["configuration_editor"] = capability_configuration_editor( capability_id, explore_harness_profiles=feature.get("profiles") or explore_harness_profiles, ) + if capability_id == "explore_harness" and "default" not in feature and previous_editor: + entry["configuration_editor"] = previous_editor if capability_id == "reward_memory": for field in entry["configuration_editor"]["fields"]: if field["key"] == "agent_id": @@ -669,6 +677,31 @@ def _attach_effective_configuration(entry: dict[str, Any]) -> None: "planning_horizon_goal": DEFAULT_PLANNING_HORIZON_GOAL, **goal_override} machine_default = None if machine_default is None else { "planning_horizon_goal": DEFAULT_PLANNING_HORIZON_GOAL, **machine_default} + if entry["capability_id"] == "explore_harness": + from .explore.machine_defaults import resolve_explore_configuration + from ..explore_graph import plan_explore_configuration + + raw = dict(goal_override or {}) + graph = {} + if "mode" in raw: + planned = plan_explore_configuration({}, {"mode": raw.pop("mode")}) + graph["enabled"] = planned["evidence_enabled"] + raw["enabled"] = planned["planning_enabled"] + resolved = resolve_explore_configuration( + {"explore_graph": graph, "spawn_policy": {"explore_harness": raw}}, + machine_default.get("mode") if machine_default is not None else None, + ) + value = resolved["configuration"] + source = resolved["mode_source"] + entry["effective_configuration"] = { + "schema_version": CAPABILITY_CONFIGURATION_RESOLUTION_SCHEMA, + "capability_id": "explore_harness", "configuration": value, + "source": source, "inherited": source == "machine_default", + "goal_override_present": goal_override is not None, + "machine_default_present": machine_default is not None, + "effective_revision": configuration_payload_revision({"source": source, "configuration": value}), + } + return entry["effective_configuration"] = resolve_capability_configuration( str(entry["capability_id"]), goal_override=goal_override, diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index 8a7a83ed3b..b90294385f 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -542,9 +542,9 @@ dependent* todo chains. It must not be used to size independent parallel worker lanes -- that misuse capped an early calibration run's treatment arm at 5 of 10 lanes; worker plans now use `schedule_independent_lanes` instead. -The command is read-only and sits behind the same per-goal opt-in gate as +The command is read-only and sits behind the same effective Goal-mode gate as `worker-branch-plan` (see "Per-Goal Opt-In Gate" below): without -`explore_harness.enabled=true` on the goal's orchestration boundary it returns +`explore_harness.enabled=true` on the Goal's effective orchestration boundary it returns a disabled packet, and `--width` is capped by `max_children` in addition to its own ceiling. It does not claim todos, acquire leases, launch agents, spend quota, or change the active state. Instead it emits a prediction @@ -673,7 +673,7 @@ do not claim todos, acquire leases, launch workers, write state, or grant quota authority. They only constrain the predicted portfolio; execution still enters the normal LoopX lifecycle described below. -This command is read-only and opt-in per goal. It is designed to sit on top of +This command is read-only and requires an enabled effective Goal mode. It is designed to sit on top of the existing LoopX harness, not beside it and not instead of it: 1. LoopX supplies the harness inputs: quota/status context outside this command, @@ -836,11 +836,10 @@ contract does not enable Explore Harness. #### Explore Harness planning gate Both opt-in planners — `todo-branch-plan` and `worker-branch-plan` — -are deny-by-default. The gate lives on the registered goal's `spawn_policy`, -the single writable source that the quota/status pipeline projects into -`quota should-run` as `goal_boundary.orchestration`. No other registry key is -honored: a second source would be an authorization surface invisible to the -quota boundary. +are deny-by-default. The gate consumes the Goal's effective `spawn_policy`, +composed from the explicit Goal mode or live machine preference and projected +into `quota should-run` as `goal_boundary.orchestration`. Goal-owned spawn +permissions are preserved; the machine preference adds no execution authority. ```yaml # inside the registered goal entry @@ -1374,3 +1373,38 @@ recorded and fatal failures, concurrent groups without mutable-state aliasing, fatal structural preflight, record-mode malformed-item isolation with resume continuity, aggregate metric projection, and episode-only adapters without a legacy `execute` method. + + +## Machine default + +In Settings → Device → Explore Harness, choose **Off**, **Evidence**, or +**Planning**, preview, then apply. Planning includes the evidence graph and +read-only branch guidance. The default is off until the owner configures it. + +The `explore_harness` machine namespace uses +`{"schema_version":"explore_harness_machine_defaults_v0","mode":"planning"}`. +It follows the same `machine-config` preview, exact-revision apply, remove and +rollback transaction as other device preferences. Existing and new Goals with +no explicit mode inherit live; no Goal registry is rewritten. Separate runtime +roots retain their own settings, including isolated benchmark runtimes. + +Explicit Goal modes and legacy graph/harness `enabled` fields override the +machine mode, including `false`. A profile or composition setting alone does +not pin the mode. Goal settings show the effective mode and its source. Use +**Restore inheritance** or `configure-goal --clear-explore-configuration` to +remove the Goal's Explore overrides; this preserves other spawn settings. +Set an explicit Goal mode to `off` to keep that Goal disabled. + +Editing a profile or composition setting in the form or JSON preserves mode +inheritance; only an explicit mode choice creates a mode override. + +Turn context, planning, quota/history, Todo settlement, native completion and +material-refresh readers use the same mode owner. Evidence does not grant external publication authority; machine +activation retains local projection without enabling a sink. Profile, research +scope, spawn permission, leases, quota and host continuation remain separately +owned. Enabled configuration alone does not prove evidence adoption or better +outcomes. + +Native research completion retains the graph lock and, when inheriting, the +device-configuration lock until its transaction finishes. Inherited planning +requires the same exact typed experiment evidence as an explicit Goal mode. diff --git a/loopx/capabilities/explore/README.zh-CN.md b/loopx/capabilities/explore/README.zh-CN.md index 150badad63..4d5f105cf2 100644 --- a/loopx/capabilities/explore/README.zh-CN.md +++ b/loopx/capabilities/explore/README.zh-CN.md @@ -273,7 +273,7 @@ Todo 关联保留既有可恢复边界与 claim/lease 证明;恢复旧批次 关于 DSpark 引文(arXiv:2607.05147)的准确性说明:真实 DSpark 会在每个 per-step 置信度低于固定阈值的第一个位置截断半自回归 draft 块,并且只把 per-step 置信度累乘作为校准诊断。这里的 prefix-survival theta 模型(survival product × throughput 曲线)是 LoopX 特有的、面向**串行依赖** todo 链的扩展。绝不能用它来衡量独立并行 worker lanes 的规模——早期校准运行曾因此把 treatment arm 截到 5/10 lanes;worker 规划现在使用 `schedule_independent_lanes` 代替。 -命令只读,并与 `worker-branch-plan` 一样受 per-goal opt-in 门控(见下文):没有 `explore_harness.enabled=true` 时返回 disabled packet,`--width` 在自身上限之外还被 `max_children` 封顶。它不 claim todo、不 acquire lease、不 launch agent、不 spend quota、不改变 active state。它只输出预测 packet: +命令只读,并与 `worker-branch-plan` 一样受 Goal 有效模式门控(见下文):没有 `explore_harness.enabled=true` 时返回 disabled packet,`--width` 在自身上限之外还被 `max_children` 封顶。它不 claim todo、不 acquire lease、不 launch agent、不 spend quota、不改变 active state。它只输出预测 packet: - 选中的分支、置信度、hazards 与 reason codes; - 被排除的 `continuous_monitor` 诊断——仍可见,但绝不进入探索调度器或消耗分支宽度; @@ -322,7 +322,7 @@ loopx explore worker-branch-plan --goal-id --worker-width 5 \ 这仍是仅分析证据:`resource_portfolio.score_delta=0`、typed evidence 保持 `score_delta=0`、planner 只读。容量与占用不 claim todo、不 acquire lease、不 launch worker、不写状态、不授予 quota 权威。它们只约束预测 portfolio;执行仍进入下面描述的正常 LoopX 生命周期。 -该命令只读、per-goal opt-in。它设计为**叠加在现有 LoopX harness 之上**,而不是并排或取代: +该命令只读,需要开启 Goal 有效模式。它设计为**叠加在现有 LoopX harness 之上**,而不是并排或取代: 1. LoopX 提供 harness 输入:quota/status 上下文、open agent todo 投影、explore result 投影、所有权、能力、写作用域元数据。 2. opt-in planner 把 todos 分组为 worker-lane 候选,用 DSpark 风格 confidence/prefix/load 打分选择 worker branch 前缀。 @@ -332,12 +332,12 @@ loopx explore worker-branch-plan --goal-id --worker-width 5 \ ### 每 Goal 独立 Opt-In 门控 -Explore Graph 与 Explore Harness 是相互独立的可选能力。开启一个绝不开启另一个: +Explore Harness 统一提供 `off`、`evidence`、`planning` 三种模式;planning 包含证据图。旧配置字段仍兼容: - `explore_graph.enabled` 控制持久图投影与任何已配置的展示 sink。每次成功的 material `refresh-state` 事务后,LoopX 折叠 canonical Explore 证据并运行已配置 sink。语义 digest 让未变更的 refresh 成为零写操作。已配置行 sink 只有在 row/result-id readback 验证投影后才算完成。失败同步或 readback 不推进其 digest,下次 material refresh 会重试。视觉 sink 还会预检确定性交付标记:已有标记对先前写做对账而不重复发布;有界 readback 超时在该 stage batch 停止后续调用并留下可重试 receipt,而不是盲目重复远端写。 -- `spawn_policy.explore_harness.enabled` 只控制下面描述的只读分支规划器。它不创建、更新或发布图。 +- `spawn_policy.explore_harness.enabled` 开启只读分支规划及证据层,但不授予外部 sink 发布权限。 -两个门控默认缺失/false。常见模式是 Graph on、Harness off:保持 operator 面向的拓扑最新,同时不改变工作的规划方式。 +没有 Goal 显式模式时继承机器默认;两者均未配置则关闭。`evidence` 保持证据图可用,`planning` 同时提供规划建议。 ```yaml # inside the registered goal entry @@ -639,3 +639,31 @@ runtime smoke 与聚焦 pytest 模块还覆盖:可恢复 prefix 复用、prefi 在解读中区分已建立的观察、仍未知的结论和下一步有用的验证。问题已回答时, 使用已有可选 `question_update` 收束,不因整体 Goal 未完成而一直保持开放, 也不因结论被确认就自动关闭问题。历史状态与 CLI finding 默认值保持兼容。 + + +## 机器默认值 + +在「设置 → 设备 → Explore Harness」选择 **关闭**、**仅证据** 或 **证据与规划**, +预览后应用。planning 包含证据图和只读分支规划建议;未配置时仍默认关闭。 + +机器命名空间 `explore_harness` 使用 +`{"schema_version":"explore_harness_machine_defaults_v0","mode":"planning"}`, +沿用 `machine-config` 的预览、精确版本应用、移除与回滚事务。已有与新建的、没有 +显式模式的 Goal 实时继承,不批量改写注册表。独立 runtime root 保留各自设置, +包括隔离的 benchmark runtime。 + +Goal 显式模式及旧版 graph/harness `enabled` 字段优先,包括 `false`。只有 profile +或组合设置时不会固定模式。Goal 设置展示有效模式及来源;「恢复继承」或 +`configure-goal --clear-explore-configuration` 清除该 Goal 的 Explore 覆盖,保留 +其他 spawn 设置。若要单独关闭某个 Goal,显式设为 `off`。 + +在表单或 JSON 中修改 profile 或组合设置会保留模式继承;只有显式选择模式才会 +创建模式覆盖。 + +Turn 上下文、规划、quota/history、Todo 结算、原生完成操作和交付后图更新使用同一个模式解析器。证据模式 +不授予对外发布权限;机器默认开启只保留本地图投影,不自动启用外部 sink。 +profile、研究范围、spawn 权限、lease、quota 和宿主续跑仍由各自机制管理。 +配置已开启本身不代表证据已被采纳或任务效果已提升。 + +原生研究任务完成事务结束前保持图锁,继承时还保持设备配置锁。继承 planning +与 Goal 显式选择 planning 一样,要求对应任务和输入的 typed experiment 证据。 diff --git a/loopx/capabilities/explore/activation.py b/loopx/capabilities/explore/activation.py index a3592e749e..54fc57a35b 100644 --- a/loopx/capabilities/explore/activation.py +++ b/loopx/capabilities/explore/activation.py @@ -74,6 +74,7 @@ def sync_explore_graph_after_material_refresh( *, registry_path: Path, goal_id: str, + runtime_root_override: str | None = None, agent_id: str | None = None, project: Path | None = None, state_file: Path | None = None, @@ -88,6 +89,15 @@ def sync_explore_graph_after_material_refresh( """ goal = load_goal_from_registry(registry_path, goal_id) + explicit_graph_enabled = ((goal or {}).get("explore_graph") or {}).get("enabled") is True + if goal is not None: + from .machine_defaults import resolve_explore_goal + from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route + route = resolve_goal_source_runtime_route( + registry_path=registry_path, goal_id=goal_id, + runtime_root_override=runtime_root_override, + ) + goal = resolve_explore_goal(goal, Path(route["source_runtime_root"])) policy = compact_explore_graph_policy( (goal or {}).get("explore_graph"), ((goal or {}).get("spawn_policy") or {}).get("explore_harness") ) @@ -129,7 +139,7 @@ def sync_explore_graph_after_material_refresh( # reading or publishing anything. An enabled legacy Harness does not grant a # sink permission merely by supplying its local evidence graph. external_sink_delivery_authorized = external_sink_delivery_authorized and ( - ((goal or {}).get("explore_graph") or {}).get("enabled") is True + explicit_graph_enabled ) base["external_sink_delivery_authorized"] = external_sink_delivery_authorized @@ -151,6 +161,7 @@ def sync_explore_graph_after_material_refresh( result = syncer( registry_path=registry_path, goal_id=goal_id, + runtime_root_override=runtime_root_override, agent_id=agent_id, project=project, state_file=state_file, diff --git a/loopx/capabilities/explore/composition_frontier.py b/loopx/capabilities/explore/composition_frontier.py index 0a47b72742..c04586ccad 100644 --- a/loopx/capabilities/explore/composition_frontier.py +++ b/loopx/capabilities/explore/composition_frontier.py @@ -235,6 +235,8 @@ def project_live_explore_composition_frontier( ), {}, ) + from .machine_defaults import resolve_explore_goal + goal = resolve_explore_goal(goal, runtime_root) orchestration = compact_orchestration_policy(goal.get("spawn_policy")) harness = ( orchestration.get("explore_harness") diff --git a/loopx/capabilities/explore/harness_gate.py b/loopx/capabilities/explore/harness_gate.py index f920322273..3077d4cbcf 100644 --- a/loopx/capabilities/explore/harness_gate.py +++ b/loopx/capabilities/explore/harness_gate.py @@ -5,14 +5,9 @@ from ...orchestration import compact_orchestration_policy -# Per-goal opt-in gate shared by the experimental exploration planners -# (todo-branch-plan and worker-branch-plan). Planning stays disabled unless -# the registered goal's spawn_policy -- the single source the quota/status -# pipeline projects into goal_boundary.orchestration -- explicitly sets -# explore_harness.enabled=true. The gate also decides whether suggested -# claim/lease commands may be emitted (spawn_allowed) and caps lane width -# with max_children; execution authority always stays with the normal LoopX -# quota/claim/lease lifecycle. +# The typed mode owner composes explicit Goal settings over the live machine +# preference. This gate receives the resulting orchestration projection, never +# reads settings itself, and preserves Goal-owned spawn/claim/lease authority. EXPLORE_HARNESS_GATE_SCHEMA_VERSION = "loopx_explore_harness_gate_v0" GATE_STATE_DISABLED = "disabled" GATE_STATE_ANALYSIS_ONLY = "analysis_only" @@ -37,10 +32,10 @@ def explore_harness_required_contract(*, default_profile: str) -> dict[str, Any] }, }, "note": ( - "explore_harness.enabled defaults to false for every goal and must be " - "a boolean true; register the opt-in on the goal's spawn_policy (it " - "projects into quota should-run as goal_boundary.orchestration) before " - "the exploration planners will produce lanes for this goal" + "Explore planning defaults to off. Choose planning in the machine " + "settings or on this Goal; explicit Goal modes take precedence. " + "The effective mode reaches goal_boundary.orchestration without " + "granting spawn, claim, lease or quota authority." ), } diff --git a/loopx/capabilities/explore/machine_defaults.py b/loopx/capabilities/explore/machine_defaults.py new file mode 100644 index 0000000000..c922652ff6 --- /dev/null +++ b/loopx/capabilities/explore/machine_defaults.py @@ -0,0 +1,107 @@ +"""Machine preference IO and adapters to Explore's existing typed mode owner.""" +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy +from functools import lru_cache +import json +from pathlib import Path +from typing import Any + +from ...control_plane.effect_runtime import effect_runtime_result +from ...explore_graph import plan_explore_configuration +from ..machine_configuration.contract import ( + MACHINE_CONFIGURATION_SCHEMA, MachineConfigurationNamespace, +) + +EXPLORE_MACHINE_DEFAULTS_SCHEMA = "explore_harness_machine_defaults_v0" + + +def normalize_explore_machine_defaults(raw: Mapping[str, Any]) -> dict[str, Any]: + if set(raw) != {"schema_version", "mode"} or raw.get("schema_version") != EXPLORE_MACHINE_DEFAULTS_SCHEMA: + raise ValueError("explore_harness requires its schema_version and mode") + if not isinstance(raw["mode"], str): + raise ValueError("Explore machine mode must be off, evidence or planning") + return {"schema_version": EXPLORE_MACHINE_DEFAULTS_SCHEMA, "mode": _validated_mode(raw["mode"])} + + +@lru_cache(maxsize=3) +def _validated_mode(mode: str) -> str: + return str(plan_explore_configuration({}, {"mode": mode})["mode"]) + + +def explore_machine_configuration_namespace() -> MachineConfigurationNamespace: + return MachineConfigurationNamespace( + namespace="explore_harness", + schema_versions=frozenset({EXPLORE_MACHINE_DEFAULTS_SCHEMA}), + normalize=normalize_explore_machine_defaults, + project_public=lambda value: dict(value), + apply_public_update=lambda _current, update: dict(update), + title="Explore Harness", + description=("Default exploration mode for existing and new Goals. Explicit Goal modes take precedence. " + "Planning includes evidence and read-only guidance; execution authority remains separate."), + documentation={"path": "loopx/capabilities/explore/README.md#machine-default"}, + default_configuration={"schema_version": EXPLORE_MACHINE_DEFAULTS_SCHEMA, "mode": "off"}, + ) + + +@lru_cache(maxsize=128) +def _resolve(encoded: str) -> dict[str, Any]: + return effect_runtime_result("explore.configuration.goal", json.loads(encoded)) + + +def resolve_explore_configuration( + goal: Mapping[str, Any], machine_mode: str | None = None, +) -> dict[str, Any]: + spawn = goal.get("spawn_policy") or {} + # Only the capability configuration crosses the typed boundary, never the + # Goal's private context or another capability's bindings. + return deepcopy(_resolve(json.dumps({ + "graph": goal.get("explore_graph") or {}, + "harness": spawn.get("explore_harness") or {}, + "machine_mode": machine_mode, + }, sort_keys=True))) + + +def configuration_summary(goal: Mapping[str, Any]) -> dict[str, Any] | None: + return resolve_explore_configuration(goal)["goal_override"] + + +def apply_explore_machine_default( + goal: Mapping[str, Any], machine_configuration: Mapping[str, Any] | None, +) -> dict[str, Any]: + machine_mode = None + if machine_configuration is not None: + if machine_configuration.get("schema_version") != MACHINE_CONFIGURATION_SCHEMA: + raise ValueError(f"machine_configuration must use {MACHINE_CONFIGURATION_SCHEMA}") + namespaces = machine_configuration.get("namespaces") + if not isinstance(namespaces, Mapping): + raise TypeError("machine_configuration.namespaces must be an object") + raw = namespaces.get("explore_harness") + if raw is not None: + if not isinstance(raw, Mapping): + raise TypeError("explore_harness machine configuration must be an object") + machine_mode = normalize_explore_machine_defaults(raw)["mode"] + resolved = resolve_explore_configuration(goal, machine_mode) + projected = deepcopy(dict(goal)) + if resolved["mode_source"] == "machine_default": + projected["explore_graph"] = {**(projected.get("explore_graph") or {}), + "enabled": resolved["evidence_enabled"]} + spawn = dict(projected.get("spawn_policy") or {}) + harness = dict(spawn.get("explore_harness") or {}) + harness["enabled"] = resolved["planning_enabled"] + spawn["explore_harness"] = harness + projected["spawn_policy"] = spawn + return projected + + +def resolve_explore_goal(goal: Mapping[str, Any], runtime_root: Path) -> dict[str, Any]: + """Resolve live settings for callers that read the raw registry, without writes.""" + if resolve_explore_configuration(goal)["mode_source"] == "goal_override": + return deepcopy(dict(goal)) + from ..machine_configuration.builtins import build_builtin_machine_configuration_registry + from ..machine_configuration.store import read_machine_configuration + + return apply_explore_machine_default(goal, read_machine_configuration( + runtime_root, registry=build_builtin_machine_configuration_registry(), + )) diff --git a/loopx/capabilities/explore/research_evidence.py b/loopx/capabilities/explore/research_evidence.py index 8fbdb45cec..d189f1141c 100644 --- a/loopx/capabilities/explore/research_evidence.py +++ b/loopx/capabilities/explore/research_evidence.py @@ -1,6 +1,8 @@ """Transport for the Explore research contract; TypeScript owns its rules.""" from __future__ import annotations +from .machine_defaults import resolve_explore_goal + from collections.abc import Mapping from pathlib import Path from typing import Any @@ -121,7 +123,7 @@ def append_research_observation( lineage = canonical["execution_lineage"] goal = find_registry_goal(load_registry(registry_path), goal_id) or {} - harness = (goal.get("spawn_policy") or {}).get("explore_harness") or {} + harness = (resolve_explore_goal(goal, runtime_root).get("spawn_policy") or {}).get("explore_harness") or {} policy = _research_result("explore.research.composition_policy", {"harness": harness}) candidate_sources = [{"node_id": event["result_id"], "research_observation": event["research_observation"]} for event in events if event.get("research_observation")] diff --git a/loopx/capabilities/explore/research_frontier.py b/loopx/capabilities/explore/research_frontier.py index 12e886f38c..9b369eca0d 100644 --- a/loopx/capabilities/explore/research_frontier.py +++ b/loopx/capabilities/explore/research_frontier.py @@ -5,6 +5,8 @@ """ from __future__ import annotations +from .machine_defaults import resolve_explore_goal + from collections.abc import Mapping, Sequence from contextlib import contextmanager from pathlib import Path @@ -138,7 +140,7 @@ def prepare_research_replan_evidence( from .composition_frontier import project_live_explore_composition_frontier goal = {**(registry_goal or {}), "id": goal_id} - harness = (goal.get("spawn_policy") or {}).get("explore_harness") or {} + harness = (resolve_explore_goal(goal, runtime_root).get("spawn_policy") or {}).get("explore_harness") or {} if not (harness.get("enabled") is True and harness.get("composition_mode") == "explicit_only") and capability_guard is None: return None frontier = project_live_explore_composition_frontier(runtime_root=runtime_root, goal_id=goal_id, @@ -172,7 +174,7 @@ def attach_research_execution_projection( from ...control_plane.todos.contract import normalize_todo_claimed_by goal = find_registry_goal(registry, projection["goal_id"]) or {} - harness = (goal.get("spawn_policy") or {}).get("explore_harness") or {} + harness = (resolve_explore_goal(goal, runtime_root).get("spawn_policy") or {}).get("explore_harness") or {} if harness.get("enabled") is not True or harness.get("composition_mode") != "explicit_only": return registered = registered_agent_ids_for_goal(goal) @@ -214,7 +216,7 @@ def hold_research_completion_evidence( yield None return goal = find_registry_goal(load_registry(registry_path), goal_id) or {} - harness = (goal.get("spawn_policy") or {}).get("explore_harness") or {} + harness = (resolve_explore_goal(goal, runtime_root).get("spawn_policy") or {}).get("explore_harness") or {} policy = _research_result("explore.research.composition_policy", {"harness": harness}) if not policy["enabled"] or todo.get("status") == "done" or todo.get("role") == "user": yield None diff --git a/loopx/capabilities/explore/research_snapshot_host.py b/loopx/capabilities/explore/research_snapshot_host.py index 686889485d..b2c1b30bff 100644 --- a/loopx/capabilities/explore/research_snapshot_host.py +++ b/loopx/capabilities/explore/research_snapshot_host.py @@ -6,11 +6,14 @@ from __future__ import annotations import json +from contextlib import ExitStack from pathlib import Path import sys from ...entrypoint import pin_utf8_stdio from ...file_lock import exclusive_file_lock +from ..machine_configuration.store import machine_configuration_store_path +from .machine_defaults import resolve_explore_configuration, resolve_explore_goal from .research_frontier import build_research_composition_frontier from .result_log import build_explore_result_projection, explore_result_log_path, load_explore_result_events_strict @@ -22,14 +25,24 @@ def main() -> None: if not root.is_absolute(): raise ValueError("absolute source runtime required") path = explore_result_log_path(root, goal) - with exclusive_file_lock(path, operation="research-native-completion"): + configuration = {"explore_graph": request.get("graph") or {}, + "spawn_policy": {"explore_harness": request["harness"]}} + with ExitStack() as locks: + if resolve_explore_configuration(configuration)["mode_source"] != "goal_override": + # The same lock used by device apply/rollback holds the inherited + # mode stable until the native transaction releases this snapshot. + locks.enter_context(exclusive_file_lock(machine_configuration_store_path(root), + operation="research-native-configuration")) + effective = resolve_explore_goal(configuration, root) + harness = (effective.get("spawn_policy") or {}).get("explore_harness") or {} + locks.enter_context(exclusive_file_lock(path, operation="research-native-completion")) events = load_explore_result_events_strict(path, goal_id=goal) projection = build_explore_result_projection(events, goal_id=goal) frontier = build_research_composition_frontier( projection, candidate_sources=[ {"node_id": event["result_id"], "research_observation": event["research_observation"]} for event in events if event.get("research_observation") - ], harness=request["harness"], todos=request["todos"], agent_id=request["agent_id"], + ], harness=harness, todos=request["todos"], agent_id=request["agent_id"], ) print(json.dumps({"schema_version": "research_graph_snapshot_v0", "frontier": frontier}, ensure_ascii=False, allow_nan=False), flush=True) diff --git a/loopx/capabilities/explore/turn_context.py b/loopx/capabilities/explore/turn_context.py index d86cbf6cbc..ff80689aef 100644 --- a/loopx/capabilities/explore/turn_context.py +++ b/loopx/capabilities/explore/turn_context.py @@ -27,10 +27,16 @@ ) -def _policy(registry_path, goal_id): +def _policy(registry_path, goal_id, runtime_root=None): goal = load_goal_from_registry(registry_path, goal_id) if goal is None: raise ValueError(f"Goal {goal_id!r} is not registered") + from .machine_defaults import resolve_explore_goal + if runtime_root is None: + from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + runtime_root = Path(route["source_runtime_root"]) + goal = resolve_explore_goal(goal, runtime_root) graph = compact_explore_graph_policy( goal.get("explore_graph"), (goal.get("spawn_policy") or {}).get("explore_harness"), @@ -67,7 +73,7 @@ def explore_turn_context( ): if type(include_finding_digests) is not bool: raise ValueError("include_finding_digests must be an explicit boolean") - goal, graph, gate = _policy(registry_path, goal_id) + goal, graph, gate = _policy(registry_path, goal_id, runtime_root) require_registered_agent_id( registry_path=registry_path, goal_id=goal_id, agent_id=agent_id ) @@ -153,7 +159,7 @@ def extend_turn_start_dispatch( ): if not agent_id: return dispatch - _, graph, gate = _policy(registry_path, goal_id) + _, graph, gate = _policy(registry_path, goal_id, runtime_root) if not graph and not gate["enabled"]: return dispatch @@ -219,7 +225,7 @@ def produce(): return result -def project_settlement_attachment(plan, *, registry_path: Path): +def project_settlement_attachment(plan, *, registry_path: Path, runtime_root: Path | None = None): """Compose the existing optional result option into a Todo-bound CLI plan. Registry access and hook dispatch are adapters. The typed Explore owner @@ -230,7 +236,7 @@ def project_settlement_attachment(plan, *, registry_path: Path): return plan def produce(): - _, graph, _ = _policy(registry_path, identity["goal_id"]) + _, graph, _ = _policy(registry_path, identity["goal_id"], runtime_root) return { "schema_version": INTERACTION_PROJECTION_HOOK_RESULT_SCHEMA_VERSION, "hook_id": "explore.settlement_attachment", diff --git a/loopx/capabilities/issue_fix/explore_projection.py b/loopx/capabilities/issue_fix/explore_projection.py index 86d3725a01..12c528e187 100644 --- a/loopx/capabilities/issue_fix/explore_projection.py +++ b/loopx/capabilities/issue_fix/explore_projection.py @@ -16,7 +16,7 @@ from typing import Any from ...history import load_registry -from ...paths import resolve_runtime_root +from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route from ...rollout_event_log import load_rollout_events, rollout_event_log_path from ...control_plane.todos.path_resolution import resolve_todo_state_path from ...control_plane.todos.active_state_editing import section_bounds, todo_blocks @@ -651,6 +651,7 @@ def project_issue_fix_explore_graph( *, registry_path: Path, goal_id: str, + runtime_root_override: str | None = None, agent_id: str | None = None, project: Path | None = None, state_file: Path | None = None, @@ -665,7 +666,11 @@ def project_issue_fix_explore_graph( state_file=state_file, ) registry = load_registry(registry_path) - runtime_root = resolve_runtime_root(registry, registry_path=registry_path) + route = resolve_goal_source_runtime_route( + registry_path=registry_path, goal_id=goal_id, + runtime_root_override=runtime_root_override, registry=registry, + ) + runtime_root = Path(route["source_runtime_root"]) result_log = explore_result_log_path(runtime_root, goal_id) existing_events = load_explore_result_events(result_log, goal_id=goal_id) outcomes_packet = build_issue_fix_outcome_collection_from_domain_state( diff --git a/loopx/capabilities/machine_configuration/builtins.py b/loopx/capabilities/machine_configuration/builtins.py index b212686dd9..368b829221 100644 --- a/loopx/capabilities/machine_configuration/builtins.py +++ b/loopx/capabilities/machine_configuration/builtins.py @@ -28,6 +28,7 @@ def build_builtin_machine_configuration_registry() -> MachineConfigurationRegist todo_replan_cadence_machine_configuration_namespace, ) + from ..explore.machine_defaults import explore_machine_configuration_namespace from .goal_storage import goal_storage_machine_configuration_namespace from ..native_chat.machine_defaults import codex_chat_machine_configuration_namespace from .context_delivery import context_delivery_machine_configuration_namespace @@ -35,6 +36,7 @@ def build_builtin_machine_configuration_registry() -> MachineConfigurationRegist return ( MachineConfigurationRegistry() .register(context_delivery_machine_configuration_namespace()) + .register(explore_machine_configuration_namespace()) .register(codex_chat_machine_configuration_namespace()) .register(goal_storage_machine_configuration_namespace()) .register(manager_runtime_machine_configuration_namespace()) @@ -59,6 +61,9 @@ def project_goal_with_builtin_machine_configuration( apply_todo_replan_cadence_machine_default, ) + from ..explore.machine_defaults import apply_explore_machine_default + + goal = apply_explore_machine_default(goal, machine_configuration) projected = apply_todo_replan_cadence_machine_default( goal, machine_configuration ) @@ -81,7 +86,10 @@ def builtin_machine_inheritable_goal_overrides( from ..pr_review_queue.goal_configuration import configuration_summary as pr_review_summary + from ..explore.machine_defaults import configuration_summary as explore_summary + summaries = { + "explore_harness": explore_summary(goal), "pull_request_review": pr_review_summary(goal), "change_quality_qualification": change_quality_summary(goal), "todo_replan_cadence": cadence_summary(goal), diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index 681682a18e..fa7bcb8cae 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -115,9 +115,13 @@ def _explore_harness_options(config: Mapping[str, Any]) -> dict[str, Any]: raise TypeError("Explore composition mode and scope must be strings") if "mode" in config and "enabled" in config: raise ValueError("Use Explore mode or the legacy enabled flag, not both") + mode_options: dict[str, Any] = {} + if "mode" in config: + mode_options["explore_mode"] = config["mode"] + elif "enabled" in config: + mode_options["explore_harness_enabled"] = _boolean_configuration("explore_harness", config, "enabled") return { - **({"explore_mode": config["mode"]} if "mode" in config else { - "explore_harness_enabled": _boolean_configuration("explore_harness", config, "enabled")}), + **mode_options, "explore_harness_profile": profile, "clear_explore_harness_profile": profile is None, "explore_composition_mode": composition_mode or "disabled", @@ -190,6 +194,8 @@ def _goal_capability_options( """Translate one validated UI contract into configure-goal options.""" if configuration is None: + if capability_id == "explore_harness": + return {"clear_explore_configuration": True} if capability_id == "goal_capability_organization": return {"clear_capability_improvement_configuration": True} if capability_id == "periodic_report": diff --git a/loopx/cli_commands/explore.py b/loopx/cli_commands/explore.py index 1501ae6a9c..def5c35598 100644 --- a/loopx/cli_commands/explore.py +++ b/loopx/cli_commands/explore.py @@ -295,15 +295,11 @@ def _append_event_payload(event: dict[str, object], *, runtime_root: Path, goal_ return payload -def _goal_orchestration_boundary(registry: dict[str, object], *, goal_id: str) -> dict[str, object] | None: - """Per-goal orchestration policy from the registered goal's spawn_policy. - - spawn_policy is the single writable source that the live quota/status - pipeline projects into goal_boundary.orchestration (enrich_project_asset - derives project_asset.orchestration from it too), so the planner gate must - read exactly it -- honoring any other registry key would create a second - authorization surface invisible to `quota should-run`. An unregistered - goal has no boundary, which the planners treat as opt-out. +def _goal_orchestration_boundary(registry: dict[str, object], *, goal_id: str, runtime_root: Path | None = None) -> dict[str, object] | None: + """Read the Goal boundary with the same live Explore preference as quota. + + Machine mode enables guidance only; spawn permissions remain Goal-owned. + Unregistered Goals retain the disabled planner response. """ goals = registry.get("goals") @@ -312,6 +308,9 @@ def _goal_orchestration_boundary(registry: dict[str, object], *, goal_id: str) - for goal in goals: if not isinstance(goal, dict) or str(goal.get("id") or "") != goal_id: continue + if runtime_root is not None: + from ..capabilities.explore.machine_defaults import resolve_explore_goal + goal = resolve_explore_goal(goal, runtime_root) spawn_policy = goal.get("spawn_policy") return spawn_policy if isinstance(spawn_policy, dict) else None return None @@ -678,7 +677,9 @@ def handle_explore_command( args.resource_usage, flag_name="--resource-usage", ) - orchestration = _goal_orchestration_boundary(registry, goal_id=args.goal_id) + orchestration = _goal_orchestration_boundary( + registry if same_source else load_registry(source_registry), + goal_id=args.goal_id, runtime_root=runtime_root) gate = resolve_todo_branch_plan_gate(orchestration, requested_width=args.width) if gate["state"] == GATE_STATE_DISABLED: # Short-circuit before goal-state projection so disabled and @@ -729,7 +730,9 @@ def handle_explore_command( args.resource_usage, flag_name="--resource-usage", ) - orchestration = _goal_orchestration_boundary(registry, goal_id=args.goal_id) + orchestration = _goal_orchestration_boundary( + registry if same_source else load_registry(source_registry), + goal_id=args.goal_id, runtime_root=runtime_root) gate = resolve_explore_harness_gate(orchestration, requested_width=args.worker_width) if gate["state"] == GATE_STATE_DISABLED: # Short-circuit before goal-state projection so disabled and diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 5e456112d6..39036e779b 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -860,6 +860,7 @@ def handle_refresh_state_command( graph_sync = sync_explore_graph_after_material_refresh( registry_path=registry_path, goal_id=args.goal_id, + runtime_root_override=args.runtime_root, agent_id=args.agent_id, project=Path(args.project).expanduser() if args.project else None, state_file=Path(args.state_file).expanduser() if args.state_file else None, diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 671fa9291b..8723780835 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -467,6 +467,7 @@ def _emit_quota_result( if isinstance(cli, dict) and isinstance(cli.get("settlement_plan"), Mapping): cli["settlement_plan"] = project_settlement_attachment( cli["settlement_plan"], registry_path=registry_path, + runtime_root=context.runtime_root if context is not None else None, ) if context is not None: observe_quota_result( diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index 01d93dc8ff..f5f85031c1 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -517,6 +517,7 @@ def handle_registry_admin_command( explore_composition_scope_id=args.explore_composition_scope_id, explore_graph_enabled=args.explore_graph_enabled, explore_mode=args.explore_mode, + clear_explore_configuration=bool(args.clear_explore_configuration), lark_kanban_heartbeat_sync=args.lark_kanban_heartbeat_sync, registered_agents=args.registered_agents, clear_registered_agents=bool(args.clear_registered_agents), diff --git a/loopx/cli_commands/registry_admin_configure.py b/loopx/cli_commands/registry_admin_configure.py index a0622642f5..eee24a94ed 100644 --- a/loopx/cli_commands/registry_admin_configure.py +++ b/loopx/cli_commands/registry_admin_configure.py @@ -256,6 +256,10 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N action="store_true", help="Clear allowed child-agent domains.", ) + configure_goal_parser.add_argument( + "--clear-explore-configuration", action="store_true", + help="Remove Goal Explore overrides and inherit the live machine mode.", + ) configure_goal_parser.add_argument( "--explore-mode", choices=("off", "evidence", "planning"), help="Explore Harness: off, evidence only, or evidence with read-only planning. Does not grant spawn authority.", diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 0fad890fd0..999d436409 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -161,7 +161,7 @@ def _todo_settlement_plan( from ..capabilities.explore.turn_context import project_settlement_attachment - return project_settlement_attachment(plan, registry_path=registry_path) + return project_settlement_attachment(plan, registry_path=registry_path, runtime_root=runtime_root) def _validated_replan_successor_obligation( diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py index 7e50ce4c0f..18ee62fa33 100644 --- a/loopx/configuration_catalog.py +++ b/loopx/configuration_catalog.py @@ -896,6 +896,7 @@ def build_goal_configuration_catalog( feature_id = str(feature.get("feature_id") or "") if feature_id not in { "todo_replan_cadence", + "explore_harness", "change_quality_qualification", "pull_request_review", }: diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index 24901ced0b..1385d36907 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -480,6 +480,7 @@ def configure_goal( explore_composition_scope_id: str | None = None, explore_graph_enabled: bool | None = None, explore_mode: str | None = None, + clear_explore_configuration: bool = False, registered_agents: list[str] | None = None, clear_registered_agents: bool = False, peer_task_coordinator: str | None = None, @@ -979,6 +980,16 @@ def configure_goal( apply_reward_memory_goal_configuration(goal, reward_memory_plan) + if clear_explore_configuration: + if any(value is not None for value in ( + explore_mode, explore_graph_enabled, explore_harness_enabled, explore_harness_profile, + explore_composition_mode, explore_composition_scope_id, + )) or clear_explore_harness_profile: + raise ValueError("Clear Explore configuration or set Explore options, not both") + goal.pop("explore_graph", None) + if isinstance(goal.get("spawn_policy"), dict): + goal["spawn_policy"].pop("explore_harness", None) + if any(value is not None for value in (explore_mode, explore_graph_enabled, explore_harness_enabled)): existing_harness = (goal.get("spawn_policy") or {}).get("explore_harness") explore_plan = plan_explore_configuration( diff --git a/loopx/control_plane/capabilities/explore_configuration.ts b/loopx/control_plane/capabilities/explore_configuration.ts index d21798d756..d8cb1d9721 100644 --- a/loopx/control_plane/capabilities/explore_configuration.ts +++ b/loopx/control_plane/capabilities/explore_configuration.ts @@ -20,6 +20,32 @@ export function resolveExploreConfiguration(params: JsonObject): JsonObject { evidence_enabled: evidence, planning_enabled: planning}; } +/** Resolve one mode for both live runtime policy and configuration readback. + * Legacy enabled fields are explicit Goal choices, including false. A profile + * or composition setting alone does not pin the mode. No authority is added. + */ +export function resolveExploreGoalConfiguration(params: JsonObject): JsonObject { + const graph = requireJsonObject(params.graph ?? {}, "Explore graph configuration"); + const harness = requireJsonObject(params.harness ?? {}, "Explore harness configuration"); + const explicit = Object.hasOwn(graph, "enabled") || Object.hasOwn(harness, "enabled"); + const machine = params.machine_mode == null ? null : mode(params.machine_mode); + const selected = explicit + ? resolveExploreConfiguration({evidence_enabled: graph.enabled, planning_enabled: harness.enabled}) + : planExploreConfiguration({current: {}, changes: {mode: machine ?? "off"}}); + const override: JsonObject = {}; + if (explicit) override.mode = selected.mode; + for (const key of ["profile", "composition_mode", "composition_scope_id"]) { + if (Object.hasOwn(harness, key)) override[key] = harness[key]; + } + return { + ...selected, + mode_source: explicit ? "goal_override" : machine == null ? "capability_default" : "machine_default", + goal_override: Object.keys(override).length ? override : null, + configuration: {profile: "generic", composition_mode: "disabled", composition_scope_id: null, + ...override, mode: selected.mode}, + }; +} + export function planExploreConfiguration(params: JsonObject): JsonObject { const current = requireJsonObject(params.current, "Explore current configuration"); const changes = requireJsonObject(params.changes, "Explore configuration changes"); diff --git a/loopx/control_plane/capabilities/explore_research_terminal.ts b/loopx/control_plane/capabilities/explore_research_terminal.ts index 1ae08e2b2a..3015635626 100644 --- a/loopx/control_plane/capabilities/explore_research_terminal.ts +++ b/loopx/control_plane/capabilities/explore_research_terminal.ts @@ -10,6 +10,7 @@ import type {JsonObject} from "../effect_program.ts"; import type {TodoTerminalEvidenceGuard} from "../coordination/todo_terminal_lifecycle.ts"; import {requireJsonObject} from "../runtime_decode.ts"; import {normalizeResearchCompositionPolicy, qualifyResearchCompletion} from "./explore_research_execution.ts"; +import {resolveExploreGoalConfiguration} from "./explore_configuration.ts"; export class ResearchTerminalEvidenceHost { private child: ChildProcessWithoutNullStreams | null = null; @@ -37,7 +38,14 @@ export class ResearchTerminalEvidenceHost { if (matches.length > 1) throw new Error("ambiguous research Goal source"); const spawnPolicy = matches[0]?.spawn_policy as JsonObject | undefined; const harness = spawnPolicy?.explore_harness as JsonObject | undefined; - if (!harness || !normalizeResearchCompositionPolicy({harness}).enabled) return {required: false, allowed: true, evidence: null}; + const graph = matches[0]?.explore_graph as JsonObject | undefined; + const mode = resolveExploreGoalConfiguration({graph: graph ?? {}, harness: harness ?? {}}); + const policy = normalizeResearchCompositionPolicy({harness: harness ?? {}}); + // Only an explicit disabled mode can short-circuit before reading live + // device preferences. Composition scope itself remains Goal-local. + if (policy.mode === "disabled" || mode.mode_source === "goal_override" && !policy.enabled) { + return {required: false, allowed: true, evidence: null}; + } const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON; if (!python || !isAbsolute(python) || python.includes("\0")) { return {allowed: false, reason_code: "research_evidence_host_required", @@ -50,7 +58,7 @@ export class ResearchTerminalEvidenceHost { this.child.on("error", () => {}); this.child.stdin.on("error", () => {}); this.child.stderr.resume(); const replies = createInterface({input: this.child.stdout})[Symbol.asyncIterator](); this.child.stdin.write(JSON.stringify({runtime_root: this.root, goal_id: this.goal, - harness, agent_id: context.actor_agent_id ?? context.todo.claimed_by, + graph: graph ?? {}, harness: harness ?? {}, agent_id: context.actor_agent_id ?? context.todo.claimed_by, todos: context.todos.map(todo => Object.fromEntries([ "todo_id", "role", "status", "claimed_by", "replan_obligation_id", "task_class", "action_kind", "archive_state", "excluded_agents", "explore_result_node_refs", "target_key", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 89c8f99c4c..2c1a1a8e6b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -827,6 +827,7 @@ export function createEffectRuntimeHandlers( ["work_item.replan_context.project", lazyHandler(() => import("./work_items/replan_context.ts"), ({projectReplanContext}) => projectReplanContext)], ["work_item.replan_context.project_snapshot", lazyHandler(() => import("./work_items/replan_context.ts"), ({projectReplanContextSnapshot}) => projectReplanContextSnapshot)], ["explore.configuration.resolve", lazyHandler(() => import("./capabilities/explore_configuration.ts"), ({resolveExploreConfiguration}) => resolveExploreConfiguration)], + ["explore.configuration.goal", lazyHandler(() => import("./capabilities/explore_configuration.ts"), ({resolveExploreGoalConfiguration}) => resolveExploreGoalConfiguration)], ["explore.configuration.plan", lazyHandler(() => import("./capabilities/explore_configuration.ts"), ({planExploreConfiguration}) => planExploreConfiguration)], ["explore.result.normalize", lazyHandler(() => import("./capabilities/explore_result_writeback.ts"), ({normalizeExploreResultAttachment}) => normalizeExploreResultAttachment)], ["explore.execution.normalize", lazyHandler(() => import("./capabilities/explore_execution_observation.ts"), ({normalizeExploreExecutionObservation}) => normalizeExploreExecutionObservation)], diff --git a/loopx/extensions/lark/presentation/explore_results.py b/loopx/extensions/lark/presentation/explore_results.py index 47443f9df9..fe0316152d 100644 --- a/loopx/extensions/lark/presentation/explore_results.py +++ b/loopx/extensions/lark/presentation/explore_results.py @@ -1882,6 +1882,7 @@ def sync_issue_fix_explore_on_material_change( *, registry_path: Path, goal_id: str, + runtime_root_override: str | None = None, agent_id: str | None = None, project: Path | None = None, state_file: Path | None = None, @@ -1889,11 +1890,8 @@ def sync_issue_fix_explore_on_material_change( external_sink_delivery_authorized: bool = True, runner: CommandRunner = default_subprocess_runner, ) -> dict[str, Any]: - """Project issue-fix facts and sync Lark only when the graph digest changed. - - The canonical Explore result log is updated before this adapter runs. A - failed or interrupted Lark write therefore remains retryable: the stored - sink digest advances only after a successful remote sync. + """Sync changed graph digests after the canonical Explore log is updated. + Failed/interrupted Lark writes remain retryable; advance the stored sink digest only after successful remote sync. """ from ....capabilities.issue_fix.explore_projection import ( @@ -1908,10 +1906,12 @@ def sync_issue_fix_explore_on_material_change( source_registry_path, compact_source_runtime_route = resolve_explore_source_registry( registry_path=registry_path, goal_id=goal_id, + runtime_root_override=runtime_root_override, ) projection_result = project_issue_fix_explore_graph( registry_path=source_registry_path, goal_id=goal_id, + runtime_root_override=runtime_root_override, agent_id=agent_id, project=project, state_file=state_file, diff --git a/loopx/extensions/lark/presentation/explore_singleflight.py b/loopx/extensions/lark/presentation/explore_singleflight.py index 374d85d44a..f31389e62b 100644 --- a/loopx/extensions/lark/presentation/explore_singleflight.py +++ b/loopx/extensions/lark/presentation/explore_singleflight.py @@ -19,7 +19,7 @@ def default_lark_explore_config_path(registry_path: Path | None = None) -> Path: def source_lark_explore_config_path( - registry_path: Path | None, goal_id: str + registry_path: Path | None, goal_id: str, runtime_root_override: str | None = None ) -> Path: """Resolve the sink config beside the goal's canonical source registry.""" @@ -30,6 +30,7 @@ def source_lark_explore_config_path( source_registry_path, _ = resolve_explore_source_registry( registry_path=registry_path, goal_id=goal_id, + runtime_root_override=runtime_root_override, ) return default_lark_explore_config_path(source_registry_path) @@ -92,7 +93,7 @@ def explore_feishu_sync_busy_packet( def singleflight_issue_fix_material_sync( - config_path_resolver: Callable[[Path | None, str], Path], + config_path_resolver: Callable[[Path | None, str, str | None], Path], ) -> Callable[[Callable[..., dict[str, Any]]], Callable[..., dict[str, Any]]]: """Serialize an issue-fix material sync across row, visual, and checkpoint writes.""" @@ -113,7 +114,7 @@ def wrapped(*args: Any, **kwargs: Any) -> dict[str, Any]: kwargs.get("external_sink_delivery_authorized", True) ) with explore_feishu_sync_singleflight( - config_path=config_path_resolver(resolved_registry, goal_id), + config_path=config_path_resolver(resolved_registry, goal_id, kwargs.get("runtime_root_override")), execute=bool(execute and delivery_authorized), ) as acquired: if not acquired: diff --git a/loopx/extensions/lark/presentation/explore_source_guard.py b/loopx/extensions/lark/presentation/explore_source_guard.py index d608056c69..12ea7b534a 100644 --- a/loopx/extensions/lark/presentation/explore_source_guard.py +++ b/loopx/extensions/lark/presentation/explore_source_guard.py @@ -34,11 +34,12 @@ def _is_projection_derived_result_key(key: str, *, goal_id: str) -> bool: def resolve_explore_source_registry( - *, registry_path: Path, goal_id: str + *, registry_path: Path, goal_id: str, runtime_root_override: str | None = None ) -> tuple[Path, dict[str, Any]]: route = resolve_goal_source_runtime_route( registry_path=registry_path, goal_id=goal_id, + runtime_root_override=runtime_root_override, ) return ( Path(str(route["source_registry"])), diff --git a/tests/capabilities/test_capability_configuration_ui.py b/tests/capabilities/test_capability_configuration_ui.py index 4e472077f4..b2af797300 100644 --- a/tests/capabilities/test_capability_configuration_ui.py +++ b/tests/capabilities/test_capability_configuration_ui.py @@ -373,8 +373,7 @@ def test_resolution_rejects_values_for_unsupported_scopes() -> None: ("todo_replan_cadence", ["machine", "goal"]), ("multi_subagent", ["goal"]), ("peer_task_coordination", ["goal"]), - ("explore_harness", ["goal"]), - ("explore_harness", ["goal"]), + ("explore_harness", ["machine", "goal"]), ("progress_review", ["goal"]), ("reward_memory", ["goal"]), ("lark_kanban_heartbeat_sync", ["goal"]), diff --git a/tests/capabilities/test_explore_machine_defaults.py b/tests/capabilities/test_explore_machine_defaults.py new file mode 100644 index 0000000000..479ce188e8 --- /dev/null +++ b/tests/capabilities/test_explore_machine_defaults.py @@ -0,0 +1,364 @@ +"""Live preference, explicit override, and the actual Explore entrypoints.""" +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from loopx.capabilities.explore.activation import sync_explore_graph_after_material_refresh +from loopx.capabilities.explore.result_log import ( + append_explore_result_event, build_explore_node_event, explore_result_log_path, +) +from loopx.capabilities.issue_fix.explore_projection import project_issue_fix_explore_graph +from loopx.capabilities.explore.machine_defaults import ( + EXPLORE_MACHINE_DEFAULTS_SCHEMA, apply_explore_machine_default, + normalize_explore_machine_defaults, resolve_explore_goal, +) +from loopx.capabilities.explore.turn_context import ( + explore_turn_context, extend_turn_start_dispatch, project_settlement_attachment, +) +from loopx.capabilities.configuration_inspection import inspect_machine_namespaces, project_goal_configuration +from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry +from loopx.capabilities.machine_configuration.store import configure_machine_configuration +from loopx.chat_goal_configuration_api import _goal_capability_options +from loopx.configure_goal import configure_goal +from loopx.control_plane.coordination.goal_storage_initialization import initialize_goal_storage_target +from loopx.history import collect_history +from loopx.rollout_event_log import append_rollout_event, build_rollout_event, rollout_event_log_path + + +def configure_machine(root: Path, mode: str): + config = {"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "explore_harness": {"schema_version": EXPLORE_MACHINE_DEFAULTS_SCHEMA, "mode": mode}, + }} + args = dict(runtime_root=root, configuration=config, registry=build_builtin_machine_configuration_registry()) + preview = configure_machine_configuration(**args) + return configure_machine_configuration(**args, execute=True, expected_plan_revision=preview["plan_revision"]) + + +@pytest.fixture +def setup(tmp_path, request): + root = tmp_path / "runtime" + (tmp_path / "goal.md").write_text("# Goal\n\n## Agent Todos\n") + path = tmp_path / "registry.json" + goal = {"id": "research", "creation_operation_id": "goal-create:research", "status": "active", + "repo": str(tmp_path), "state_file": "goal.md", + "spawn_policy": {"spawn_allowed": False, "max_children": 2}, + "coordination": {"registered_agents": ["worker"], "storage_target": { + "schema_version": "loopx_new_goal_storage_target_v1", "provider": getattr(request, "param", "file"), + "handoff_mode": "soft_claim"}}} + path.write_text(json.dumps({"schema_version": 1, "common_runtime_root": str(root), "goals": [goal]})) + initialize_goal_storage_target(root, goal, registry_path=path) + return path, root, goal + + +@pytest.mark.parametrize("bad", [None, True, 1, "unknown", [], {}]) +def test_invalid_mode_cannot_enable_planning(bad): + with pytest.raises((ValueError, TypeError, RuntimeError)): + normalize_explore_machine_defaults({"schema_version": EXPLORE_MACHINE_DEFAULTS_SCHEMA, "mode": bad}) + + +def test_existing_and_new_goals_inherit_live_without_registry_writes(setup): + path, root, goal = setup + original = path.read_bytes() + assert resolve_explore_goal(goal, root) == goal + for mode, evidence, planning in [("planning", True, True), ("evidence", True, False), ("off", False, False)]: + configure_machine(root, mode) + for raw in (goal, {**goal, "id": "new-goal"}): + effective = resolve_explore_goal(raw, root) + assert effective["explore_graph"]["enabled"] is evidence + assert effective["spawn_policy"]["explore_harness"]["enabled"] is planning + assert effective["spawn_policy"]["spawn_allowed"] is False + assert effective["spawn_policy"]["max_children"] == 2 + assert path.read_bytes() == original + context = explore_turn_context(registry_path=path, runtime_root=root, goal_id="research", agent_id="worker") + assert context["graph_enabled"] is evidence + assert context["harness_enabled"] is planning + assert apply_explore_machine_default(goal, None) == goal + + +def test_explicit_off_and_legacy_settings_override_machine(setup): + _, root, goal = setup + configure_machine(root, "planning") + for override in ({"explore_graph": {"enabled": False}}, + {"spawn_policy": {"explore_harness": {"enabled": False}}}, + {"explore_graph": {"enabled": True}}): + raw = {**goal, **override} + assert resolve_explore_goal(raw, root) == raw + metadata = deepcopy(goal) + metadata["spawn_policy"]["explore_harness"] = {"profile": "generic", "composition_mode": "disabled"} + effective = resolve_explore_goal(metadata, root) + assert effective["spawn_policy"]["explore_harness"] == { + "enabled": True, "profile": "generic", "composition_mode": "disabled"} + assert "enabled" not in metadata["spawn_policy"]["explore_harness"] + + +def test_goal_override_clear_restores_inheritance_in_settings_and_runtime(setup): + path, root, _ = setup + configure_machine(root, "planning") + def settings(): + packet = project_goal_configuration( + configure_goal(registry_path=path, goal_id="research"), + machine_namespaces=inspect_machine_namespaces(root)) + return next(c for c in packet["capability_catalog"]["capabilities"] if c["capability_id"] == "explore_harness") + inherited = settings() + assert inherited["effective_configuration"]["source"] == "machine_default" + assert inherited["effective_configuration"]["configuration"]["mode"] == "planning" + assert "current" not in inherited + configure_goal(registry_path=path, goal_id="research", explore_mode="off", execute=True) + assert settings()["effective_configuration"]["source"] == "goal_override" + clear = _goal_capability_options("explore_harness", None) + original = path.read_bytes() + configure_goal(registry_path=path, goal_id="research", **clear) + assert path.read_bytes() == original + with pytest.raises(ValueError, match="Clear Explore"): + configure_goal(registry_path=path, goal_id="research", explore_mode="planning", execute=True, **clear) + assert path.read_bytes() == original + configure_goal(registry_path=path, goal_id="research", execute=True, **clear) + assert settings()["effective_configuration"]["source"] == "machine_default" + goal = json.loads(path.read_text())["goals"][0] + assert goal["spawn_policy"]["spawn_allowed"] is False + assert "explore_harness" not in goal["spawn_policy"] + assert "explore_graph" not in goal + assert resolve_explore_goal(goal, root)["spawn_policy"]["explore_harness"]["enabled"] is True + + +def test_runtime_hooks_history_cli_and_material_refresh_use_same_default(setup): + path, root, _ = setup + configure_machine(root, "planning") + history = collect_history(registry_path=path, runtime_root=root, goal_id="research", limit=1) + assert history["goals"][0]["spawn_policy"]["explore_harness"]["enabled"] is True + hooks = extend_turn_start_dispatch({}, registry_path=path, runtime_root=root, goal_id="research", agent_id="worker") + assert any(read["kind"] == "explore_turn_context" for read in hooks["required_reads"]) + plan = {"identity": {"goal_id": "research", "agent_id": "worker", "todo_id": "todo-one"}, + "ordered_steps": [{"kind": "durable_writeback", "command_template": "loopx refresh-state"}]} + assert project_settlement_attachment(plan, registry_path=path)["ordered_steps"][0]["optional_attachments"] + calls = [] + def syncer(**kwargs): + calls.append(kwargs) + return {"ok": True, "status": "not_configured"} + sync_explore_graph_after_material_refresh(registry_path=path, goal_id="research", syncer=syncer) + assert calls + assert calls[0]["external_sink_delivery_authorized"] is False + for command in ("turn-context", "todo-branch-plan", "worker-branch-plan"): + result = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(path), + "--runtime-root", str(root), "--format", "json", "explore", command, + "--goal-id", "research", "--agent-id", "worker"], capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + packet = json.loads(result.stdout) + assert packet.get("harness_enabled", packet.get("enabled")) is True, packet + + +def test_runtime_roots_do_not_share_defaults_and_null_legacy_fields_are_absent(setup, tmp_path): + _, root, goal = setup + configure_machine(root, "planning") + isolated = tmp_path / "isolated-runtime" + assert resolve_explore_goal(goal, isolated) == goal + legacy = {**goal, "spawn_policy": None, "explore_graph": None} + assert resolve_explore_goal(legacy, root)["spawn_policy"]["explore_harness"]["enabled"] is True + configure_machine(isolated, "off") + assert resolve_explore_goal(goal, root)["spawn_policy"]["explore_harness"]["enabled"] is True + assert resolve_explore_goal(goal, isolated)["spawn_policy"]["explore_harness"]["enabled"] is False + + +@pytest.mark.parametrize("registry_mode,selected_mode", [("off", "planning"), ("planning", "off")]) +def test_todo_settlement_plan_uses_selected_runtime(setup, tmp_path, registry_mode, selected_mode): + import argparse + from loopx.cli_commands.todo import _todo_settlement_plan + from loopx.control_plane.quota.settlement import SettlementIdentity + + path, root, _ = setup + selected = tmp_path / "selected-runtime" + configure_machine(root, registry_mode) + configure_machine(selected, selected_mode) + plan = _todo_settlement_plan( + SettlementIdentity(goal_id="research", agent_id="worker", todo_id="todo-one", turn_instance_id="fixture-turn"), + args=argparse.Namespace(), registry_path=path, runtime_root=selected, goal_ref=None, + ) + writeback = next(step for step in plan["ordered_steps"] if step["kind"] == "durable_writeback") + assert bool(writeback.get("optional_attachments")) is (selected_mode == "planning") + assert str(selected) in writeback["command_template"] + + +@pytest.mark.parametrize("setup", ["file", "sqlite"], indirect=True) +@pytest.mark.parametrize("registry_mode,selected_mode", [("off", "planning"), ("planning", "off")]) +def test_refresh_state_uses_selected_runtime_for_graph_activation(setup, tmp_path, registry_mode, selected_mode): + path, selected, _ = setup + root = tmp_path / "registry-runtime" + registry = json.loads(path.read_text()) + registry["common_runtime_root"] = str(root) + path.write_text(json.dumps(registry)) + configure_machine(root, registry_mode) + configure_machine(selected, selected_mode) + for runtime, label in [(selected, "selected"), (root, "other-runtime")]: + for node_id in ("fix_pr_lane", f"{label}-evidence"): + append_explore_result_event(explore_result_log_path(runtime, "research"), + build_explore_node_event(goal_id="research", node_id=node_id, title=node_id)) + append_rollout_event(rollout_event_log_path(runtime, "research"), + build_rollout_event(goal_id="research", event_kind="capability_gap", status="found", + summary=f"{label} observation", details={"target_capabilities": f"{label}-capability"})) + # The oracle selects the intended source directly, without the new override + # plumbing. Distinct persisted rows and rollout facts expose cross-root reads. + oracle_registry = tmp_path / "oracle-registry.json" + oracle_registry.write_text(json.dumps({**registry, "common_runtime_root": str(selected)})) + expected = project_issue_fix_explore_graph(registry_path=oracle_registry, goal_id="research") + graph = json.dumps(expected["projection"]) + assert "selected-evidence" in graph and "selected-capability" in graph + assert "other-runtime-evidence" not in graph and "other-runtime-capability" not in graph + other_before = {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()} + registry_before = path.read_bytes() + + result = subprocess.run( + [sys.executable, "-m", "loopx.cli", "--registry", str(path), + "--runtime-root", str(selected), "--format", "json", "refresh-state", + "--goal-id", "research", "--suppress-external-sinks", "--no-global-sync"], + capture_output=True, text=True, timeout=60, cwd=tmp_path, + ) + assert result.returncode == 0, result.stdout + result.stderr + packet = json.loads(result.stdout) + assert packet["appended"] is True + assert Path(packet["runtime_root"]) == selected + activation = packet["explore_graph_sync"] + assert activation["enabled"] is (selected_mode == "planning") + assert activation["status"] == ("not_configured" if selected_mode == "planning" else "disabled") + if selected_mode == "planning": + assert activation["semantic_digest"] == expected["semantic_digest"] + assert activation["source_runtime_route"]["status"] == "explicit_override" + assert activation["external_sink_delivery_authorized"] is False + assert activation["delivery_postcondition"]["satisfied"] is True + assert path.read_bytes() == registry_before + assert not (root / "goals" / "research" / "runs" / "index.jsonl").exists() + assert (selected / "goals" / "research" / "runs" / "index.jsonl").is_file() + assert {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()} == other_before + + +@pytest.mark.parametrize("explicit_override", [False, True]) +def test_material_refresh_preserves_source_route_without_runtime_override(setup, tmp_path, explicit_override): + path, source, goal = setup + shared = tmp_path / "shared-runtime" + configure_machine(source, "planning") + configure_machine(shared, "off") + global_path = tmp_path / "registry.global.json" + global_path.write_text(json.dumps({ + "schema_version": 1, "registry_role": "global-local", "common_runtime_root": str(shared), + "goals": [{**goal, "source_registry": str(path)}], + })) + calls = [] + + def syncer(**kwargs): + calls.append(kwargs) + return {"ok": True, "status": "not_configured"} + + result = sync_explore_graph_after_material_refresh( + registry_path=global_path, goal_id="research", syncer=syncer, + runtime_root_override=str(shared) if explicit_override else None, + ) + assert result["enabled"] is not explicit_override + assert result["status"] == ("disabled" if explicit_override else "not_configured") + assert len(calls) == (0 if explicit_override else 1) + if calls: + assert calls[0]["external_sink_delivery_authorized"] is False + + +@pytest.mark.parametrize("explicit_override", [False, True]) +@pytest.mark.parametrize("delivery_authorized", [False, True]) +def test_material_sync_routes_populated_sources_and_sink_lock(setup, tmp_path, monkeypatch, + explicit_override, delivery_authorized): + from loopx.extensions.lark.presentation.explore_results import sync_issue_fix_explore_on_material_change + from loopx.file_lock import try_exclusive_file_lock + + _, source, goal = setup + monkeypatch.chdir(tmp_path) + shared = tmp_path / "shared-runtime" + source_registry = tmp_path / "source" / ".loopx" / "registry.json" + global_registry = tmp_path / "shared" / ".loopx" / "registry.global.json" + for registry, runtime, source_ref in [(source_registry, source, None), (global_registry, shared, source_registry)]: + registry.parent.mkdir(parents=True) + registry.write_text(json.dumps({"schema_version": 1, "registry_role": "global-local" if source_ref else "project", + "common_runtime_root": str(runtime), "goals": [{**goal, **({"source_registry": str(source_ref)} if source_ref else {})}]})) + for node_id in ("fix_pr_lane", f"{runtime.name}-evidence"): + append_explore_result_event(explore_result_log_path(runtime, "research"), + build_explore_node_event(goal_id="research", node_id=node_id, title=node_id)) + (registry.parent / "lark-explore.json").write_text(json.dumps({ + "schema_version": "loopx_lark_explore_local_config_v0", "board": { + "base_token": "BASE_FIXTURE", "tables": {"nodes": "tblNodes", "edges": "tblEdges", "findings": "tblFindings"}, + "cli_bin": "lark-cli", "identity": "user"}})) + selected = shared if explicit_override else source + excluded = source if explicit_override else shared + selected_registry = global_registry if explicit_override else source_registry + config = selected_registry.parent / "lark-explore.json" + config_before = config.read_bytes() + excluded_before = {p.relative_to(excluded): p.read_bytes() for p in excluded.rglob("*") if p.is_file()} + + def refuse_external_command(*args, **kwargs): + raise AssertionError("mode selection does not authorize external delivery") + + with try_exclusive_file_lock(config.with_name(config.name + ".feishu-sync"), operation="fixture_sync") as lock: + assert lock is not None + result = sync_issue_fix_explore_on_material_change( + registry_path=global_registry, goal_id="research", execute=True, + runtime_root_override="shared-runtime" if explicit_override else None, + external_sink_delivery_authorized=delivery_authorized, runner=refuse_external_command) + if delivery_authorized: + assert result["status"] == "sync_busy" + assert result["external_write_performed"] is False + else: + assert result["status"] == "external_sink_suppressed" + nodes = {node["node_id"] for node in result["projection"]["projection"]["nodes"]} + assert f"{selected.name}-evidence" in nodes + assert f"{excluded.name}-evidence" not in nodes + expected = project_issue_fix_explore_graph(registry_path=selected_registry, goal_id="research", + runtime_root_override=str(selected)) + assert result["semantic_digest"] == expected["semantic_digest"] + assert result["projection"]["source_runtime_route"]["status"] == ("explicit_override" if explicit_override else "source_registry") + assert config.read_bytes() == config_before + assert {p.relative_to(excluded): p.read_bytes() for p in excluded.rglob("*") if p.is_file()} == excluded_before + + +@pytest.mark.parametrize("override", [ + {"profile": "adaptive-resilient"}, + {"composition_mode": "explicit_only", "composition_scope_id": "bounded-scope"}, + {"mode": "evidence", "profile": "adaptive-resilient"}, + {"mode": "off"}, +]) +def test_sparse_goal_form_roundtrip_preserves_mode_intent(setup, override): + path, root, _ = setup + configure_machine(root, "planning") + configure_goal(registry_path=path, goal_id="research", execute=True, + **_goal_capability_options("explore_harness", override)) + + def settings(): + packet = project_goal_configuration(configure_goal(registry_path=path, goal_id="research"), + machine_namespaces=inspect_machine_namespaces(root)) + return next(c for c in packet["capability_catalog"]["capabilities"] if c["capability_id"] == "explore_harness") + + before = settings() + # Use the shipped form projector and writer, then the real configuration + # handler. Neither the readback nor the submitted mode is hand-filled. + module = Path(__file__).resolve().parents[2] / "apps/presentation/dashboard/src/data/capability-configuration.ts" + script = """ +import {goalExploreEditorValue, goalExploreWriteValue, projectEditableCapabilityConfiguration} from process.env.EDITOR_MODULE; +""".replace("from process.env.EDITOR_MODULE", f"from {json.dumps(module.as_uri())}") + """ +const selected = JSON.parse(process.argv[1]); +const draft = projectEditableCapabilityConfiguration(selected.configuration_editor, + goalExploreEditorValue(selected.current, selected.effective_configuration.configuration), selected.default); +console.log(JSON.stringify({mode: draft.mode, write: goalExploreWriteValue({...draft, profile: 'generic'}, + Object.hasOwn(selected.current ?? {}, 'mode'))})); +""" + process = subprocess.run(["node", "--experimental-strip-types", "--input-type=module", "-e", script, + json.dumps(before)], capture_output=True, text=True, check=True) + form = json.loads(process.stdout) + assert form["mode"] == override.get("mode", "planning") + assert ("mode" in form["write"]) is ("mode" in override) + configure_goal(registry_path=path, goal_id="research", execute=True, + **_goal_capability_options("explore_harness", form["write"])) + after = settings()["effective_configuration"] + assert after["configuration"]["profile"] == "generic" + assert after["configuration"]["mode"] == override.get("mode", "planning") + assert after["source"] == ("goal_override" if "mode" in override else "machine_default") + configure_machine(root, "off") + assert settings()["effective_configuration"]["configuration"]["mode"] == override.get("mode", "off") diff --git a/tests/control_plane/test_research_execution_authority.py b/tests/control_plane/test_research_execution_authority.py index 1bef83996c..b631fd053f 100644 --- a/tests/control_plane/test_research_execution_authority.py +++ b/tests/control_plane/test_research_execution_authority.py @@ -32,6 +32,42 @@ def observation(node: str) -> dict: } +def test_native_snapshot_holds_inherited_mode_and_graph_until_release(tmp_path): + from loopx.capabilities.machine_configuration.store import machine_configuration_store_path + from loopx.file_lock import exclusive_file_lock, LockAcquireTimeoutError + + root = tmp_path / "runtime" + configuration = machine_configuration_store_path(root) + configuration.parent.mkdir(parents=True) + configuration.write_text(json.dumps({"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "explore_harness": {"schema_version": "explore_harness_machine_defaults_v0", "mode": "planning"}}})) + request = {"runtime_root": str(root), "goal_id": "locked-research", "graph": {}, + "harness": {"composition_mode": "explicit_only", "composition_scope_id": "scope-fixture"}, + "todos": [], "agent_id": "fixture-agent"} + process = subprocess.Popen([sys.executable, "-m", "loopx.capabilities.explore.research_snapshot_host"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + process.stdin.write(json.dumps(request) + "\n") + process.stdin.flush() + snapshot = json.loads(process.stdout.readline()) + assert snapshot["frontier"]["enabled"] is True + paths = [configuration, explore_result_log_path(root, "locked-research")] + for path in paths: + with pytest.raises(LockAcquireTimeoutError): + with exclusive_file_lock(path, timeout_seconds=0): + pytest.fail("native completion released its decision source before the transaction") + process.stdin.write("release\n") + process.stdin.flush() + assert process.wait(timeout=10) == 0 + for path in paths: + with exclusive_file_lock(path, timeout_seconds=1): + pass + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=10) + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("completed_before_invalidation", [False, True]) def test_transitive_research_eligibility_preserves_completed_history( @@ -213,9 +249,12 @@ def test_execution_attribution_reads_promoted_provider( @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("resolution", ["observed", "dismissed", "deferred"]) -@pytest.mark.parametrize("diagnostic_timing", [None, "before_activation", "after_activation"]) +@pytest.mark.parametrize("mode_source,diagnostic_timing", [ + ("goal", None), ("goal", "before_activation"), ("goal", "after_activation"), ("machine", None), +]) def test_native_completion_refuses_missing_result_and_accepts_exact_observation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, resolution: str, diagnostic_timing: str | None, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, resolution: str, + mode_source: str, diagnostic_timing: str | None, ) -> None: isolate_sqlite_runtime(tmp_path, monkeypatch) try: @@ -224,9 +263,18 @@ def test_native_completion_refuses_missing_result_and_accepts_exact_observation( state.write_text("# Goal\n\n## Agent Todo\n\n") runtime, registry = tmp_path / "runtime", tmp_path / "registry.json" harness = {"enabled": True, "composition_mode": "explicit_only", "composition_scope_id": "scope-joint"} + stored_harness = {key: value for key, value in harness.items() if key != "enabled"} if mode_source == "machine" else harness + if mode_source == "machine": + from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry + from loopx.capabilities.machine_configuration.store import configure_machine_configuration + args = dict(runtime_root=runtime, registry=build_builtin_machine_configuration_registry(), configuration={ + "schema_version": "loopx_machine_configuration_v0", "namespaces": {"explore_harness": { + "schema_version": "explore_harness_machine_defaults_v0", "mode": "planning"}}}) + preview = configure_machine_configuration(**args) + configure_machine_configuration(**args, execute=True, expected_plan_revision=preview["plan_revision"]) registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ "id": goal, "repo": str(tmp_path), "state_file": state.name, "status": "active", - "spawn_policy": {"explore_harness": {"enabled": True} if diagnostic_timing == "before_activation" else harness}, + "spawn_policy": {"explore_harness": {"enabled": True} if diagnostic_timing == "before_activation" else stored_harness}, "coordination": {"agent_model": "peer_v1", "registered_agents": [agent]}, }]})) def cli(*args: str, success: bool = True) -> dict: diff --git a/tests/control_plane_ts/explore_configuration.test.ts b/tests/control_plane_ts/explore_configuration.test.ts new file mode 100644 index 0000000000..5c6a5e64f3 --- /dev/null +++ b/tests/control_plane_ts/explore_configuration.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {resolveExploreGoalConfiguration} from "../../loopx/control_plane/capabilities/explore_configuration.ts"; + +const resolve = resolveExploreGoalConfiguration; +test("machine preference inherits live while Goal modes retain priority", () => { + const goal = {graph: {}, harness: {profile: "generic", composition_mode: "disabled"}}; + assert.equal(resolve(goal).mode, "off"); + for (const mode of ["off", "evidence", "planning"]) { + const inherited = resolve({...goal, machine_mode: mode}); + assert.equal(inherited.mode, mode); + assert.equal(inherited.mode_source, "machine_default"); + assert.equal(inherited.configuration.profile, "generic"); + assert.equal(inherited.goal_override.mode, undefined); + } + assert.deepEqual(goal.graph, {}); + assert.equal(resolve({harness: {enabled: false}, machine_mode: "planning"}).mode, "off"); + assert.equal(resolve({graph: {enabled: true}, machine_mode: "planning"}).mode, "evidence"); + const legacy = resolve({graph: {enabled: false}, harness: {enabled: true}, machine_mode: "off"}); + assert.equal(legacy.mode, "planning"); + assert.equal(legacy.evidence_enabled, true); + assert.equal(legacy.mode_source, "goal_override"); + assert.throws(() => resolve({machine_mode: "unexpected"}), /explore_mode/); +}); diff --git a/tests/test_chat_machine_configuration_api.py b/tests/test_chat_machine_configuration_api.py index 5db52c40fc..f6948d870e 100644 --- a/tests/test_chat_machine_configuration_api.py +++ b/tests/test_chat_machine_configuration_api.py @@ -342,6 +342,7 @@ def test_inspection_lists_registered_namespaces_without_local_refs( "change_quality_qualification", "codex_chat", "context_delivery", + "explore_harness", "goal_storage", "manager_runtime", "periodic_report", @@ -466,6 +467,7 @@ def test_machine_catalog_discovers_goal_features_without_granting_machine_writes assert item["machine_namespace"] == capability_id assert item["configuration_editor"]["writable_scopes"] == ["machine"] elif capability_id not in { + "explore_harness", "periodic_report", "todo_replan_cadence", "change_quality_qualification", @@ -862,3 +864,44 @@ def test_public_api_update_preserves_provider_private_namespace_state( "enabled": True, "secret": "keep-me", } + + +def test_explore_machine_mode_uses_real_http_preview_apply_and_readback(tmp_path: Path) -> None: + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.runtime_root = tmp_path + server.verbose = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + connection = http.client.HTTPConnection(*server.server_address, timeout=10) + + def request(method, path, body=None): + connection.request(method, path, body=json.dumps(body) if body is not None else None, + headers={"Content-Type": "application/json"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + + try: + _, inspection = request("GET", CHAT_MACHINE_CONFIGURATION_PATH) + entry = next(c for c in inspection["capability_catalog"]["capabilities"] if c["capability_id"] == "explore_harness") + assert entry["available_scopes"] == ["machine", "goal"] + assert [f["key"] for f in entry["configuration_editor"]["fields"]] == ["mode"] + desired = {"namespace": "explore_harness", "namespace_configuration": { + "schema_version": "explore_harness_machine_defaults_v0", "mode": "planning"}} + status, preview = request("POST", CHAT_MACHINE_CONFIGURATION_PREVIEW_PATH, desired) + assert status == 201 + _, unchanged = request("GET", CHAT_MACHINE_CONFIGURATION_PATH) + assert unchanged["machine_configuration"] is None + status, rejected = request("POST", CHAT_MACHINE_CONFIGURATION_APPLY_PATH, + {**desired, "expected_plan_revision": "stale"}) + assert status >= 400 and rejected["ok"] is False + status, applied = request("POST", CHAT_MACHINE_CONFIGURATION_APPLY_PATH, + {**desired, "expected_plan_revision": preview["plan_revision"]}) + assert status == 200 and applied["ok"] is True + _, inspection = request("GET", CHAT_MACHINE_CONFIGURATION_PATH) + assert inspection["machine_configuration"]["namespaces"]["explore_harness"] == desired["namespace_configuration"] + finally: + connection.close() + server.shutdown() + thread.join(timeout=5) + server.server_close()