From b9fc98d6e4a049fdd02fe599f22a00676e829f71 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 10 Oct 2026 10:58:57 +0800 Subject: [PATCH 1/6] feat(progress-review): bind observations to current task criteria Signed-off-by: song --- apps/presentation/dashboard/src/data/chat.ts | 15 +++++ .../goal-capability-settings.tsx | 42 ++++++++++-- .../personal-workspace/personal-workspace.css | 6 ++ loopx/capabilities/progress_review/context.py | 46 +++++++++++-- loopx/capabilities/progress_review/receipt.py | 11 +++ loopx/chat_goal_configuration_api.py | 8 +++ .../control_plane/effect_runtime_handlers.ts | 2 + .../work_items/external_progress_review.py | 8 +-- .../work_items/progress_review_evidence.ts | 67 +++++++++++++++++++ packages/loopx-jev/src/loopx_jev/drift.py | 34 +++++++++- packages/loopx-jev/src/loopx_jev/runner.py | 40 ++++++++++- 11 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 loopx/control_plane/work_items/progress_review_evidence.ts diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 53ab43680f..3d42a62f0c 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1650,6 +1650,20 @@ export const capabilityConfigurationEditorSchema = z.object({ read_only_reason: z.string().optional(), }); +export const progressReviewObservationSchema = z.object({ + mode: z.string(), read_state: z.enum(["observed", "missing", "unavailable"]), read_error: z.string().optional(), + receipt_count: z.number(), rejected_receipts: z.number(), stale_receipts: z.number(), + contract_revision: z.string().nullable(), authority: z.literal("none"), + latest: z.object({status: z.string(), event_id: z.string(), evidence_id: z.string(), + judgments: z.object({choice: z.object({relation: z.string().nullable(), increment: z.string().nullable()}).nullable(), + noul: z.object({serves_acceptance: z.number().nullable(), evidence_increment: z.number().nullable(), behavior_change: z.number().nullable()}).nullable()}), + criterion_current: z.boolean().optional(), + evidence_scope: z.object({coverage: z.literal("declared_file_net_change"), files: z.array(z.string()), + criterion_binding: z.object({origin: z.enum(["operator_study", "goal_acceptance"]), criteria_sha256: z.string(), + todo_id: z.string().optional(), contract_digest: z.string().optional(), contract_revision: z.number().optional(), + criterion_ids: z.array(z.string()).optional()})}).optional(), + }).nullable(), +}); export const capabilityConfigurationCatalogSchema = z.object({ schema_version: z.literal("capability_configuration_catalog_v0"), capabilities: z.array(z.object({ @@ -1675,6 +1689,7 @@ export const capabilityConfigurationCatalogSchema = z.object({ effective_revision: z.string(), }).optional(), documentation: z.record(z.string(), z.unknown()).optional(), + observation: progressReviewObservationSchema.optional(), context_contribution: z.object({ supported_phases: z.array(z.enum(["before_plan", "before_delegate", "after_delegate_result"])), target: z.literal("coordinator"), 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 c246f65cbe..6339ba8475 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 @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, Code2, LoaderCircle, RefreshCw } from "lucide-react"; import { @@ -273,6 +273,33 @@ function CapabilityCatalog({ callbacks, catalog, goalId, notification, onApplied {localizedSelected.capability_id === "content_ops" ? : } + {selected.capability_id === "progress_review" && selected.observation ?
+

{locale === "zh-CN" ? "审查证据读回" : "Review evidence readback"}

+ +

{selected.observation.read_state === "unavailable" ? (locale === "zh-CN" ? "审查存储不可读,当前判断未知。" : "Review storage unavailable; current judgment unknown.") + : selected.observation.read_state === "missing" ? (locale === "zh-CN" ? "尚无审查回执。" : "No review receipts yet.") + : `${selected.observation.receipt_count} ${locale === "zh-CN" ? "条已读取回执" : "loaded receipts"}`}

+

{locale === "zh-CN" ? "过期 / 拒收" : "Stale / rejected"}: {selected.observation.stale_receipts} / {selected.observation.rejected_receipts}

+ {selected.observation.latest ? <> +

{locale === "zh-CN" ? "最新审查" : "Latest review"}: {selected.observation.latest.status}

+

{locale === "zh-CN" ? "与目标的关系" : "Relation to goal"}: {selected.observation.latest.judgments.choice?.relation ?? "unknown"}

+

{locale === "zh-CN" ? "新证据增量" : "Evidence increment"}: {selected.observation.latest.judgments.choice?.increment ?? "unknown"}

+ {selected.observation.latest.evidence_scope ? <> +

{selected.observation.latest.evidence_scope.criterion_binding.origin === "goal_acceptance" + ? (locale === "zh-CN" ? "绑定任务的规范验收条款" : "Bound to canonical task acceptance criteria") + : (locale === "zh-CN" ? "依据操作者研究条款;不证明规范验收关联" : "Operator study basis; canonical acceptance binding is unverified")}

+ {selected.observation.latest.criterion_current === false ?

{locale === "zh-CN" ? "条款或任务关联已变化,旧判断不可作为当前依据。" : "Criteria or task binding changed; the old judgment is unavailable for current use."}

: null} +

{locale === "zh-CN" ? "仅观察声明文件在两个检查点之间的净变化" : "Observed only the net change of declared files between two checkpoints"}

+
{locale === "zh-CN" ? "观察范围与条款版本" : "Observed scope and criterion version"} + {selected.observation.latest.evidence_scope.criterion_binding.criteria_sha256} +

{selected.observation.latest.evidence_scope.criterion_binding.criterion_ids?.join(", ")}

+

{selected.observation.latest.evidence_scope.files.join(", ")}

+
+ :

{locale === "zh-CN" ? "旧回执未记录所选条款及文件覆盖范围。" : "Legacy receipt did not record selected criteria or file coverage."}

} + : null} +

{locale === "zh-CN" ? "此观察不证明任务完成;未命中漂移条件也不等于工作符合目标。" : "This observation does not prove completion; a drift condition not met does not establish on-goal work."}

+
: null} + {localizedSelected.capability_id === "lark_event_inbox" ? (
@@ -361,18 +388,23 @@ export function GoalCapabilitySettings({ callbacks, goalId, notification, onChan const [inspection, setInspection] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const readGeneration = useRef(0); function load() { if (!goalId) return; + const generation = ++readGeneration.current; setLoading(true); setError(null); + // Withdraw the previous observation during a recheck; preserve navigation. + setInspection(current => current ? {...current, capability_catalog: {...current.capability_catalog, + capabilities: current.capability_catalog.capabilities.map(item => item.observation ? {...item, observation: undefined} : item)}} : null); void fetchGoalConfiguration(goalId) - .then(setInspection) - .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : t("capabilities.loadFailed"))) - .finally(() => setLoading(false)); + .then(value => {if (generation === readGeneration.current) setInspection(value);}) + .catch((reason: unknown) => {if (generation === readGeneration.current) setError(reason instanceof Error ? reason.message : t("capabilities.loadFailed"));}) + .finally(() => {if (generation === readGeneration.current) setLoading(false);}); } - useEffect(load, [goalId]); + useEffect(() => {setInspection(null); load(); return () => {readGeneration.current++;};}, [goalId]); if (!goalId) { return

{t("capabilities.chooseGoal")}

; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 32040d72f4..95ad18a5cc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -902,6 +902,12 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-capability-editor-status p { margin-top: 3px; } .personal-capability-editor-status.is-preview { background: var(--pw-blue-soft); color: var(--pw-blue-ink); } .personal-capability-editor-status.is-read-only { background: #fff5e8; color: #9a5a10; } +.personal-capability-observation { display: grid; gap: 10px; margin: 20px 24px; min-width: 0; overflow-wrap: anywhere; } +.personal-capability-observation h3 { margin: 0; font-size: 14px; font-weight: 600; } +.personal-capability-observation button { justify-self: start; min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; font-size: 12px; cursor: pointer; } +.personal-capability-observation details { font-size: 12px; } +.personal-capability-observation summary { min-height: 32px; cursor: pointer; } +.personal-capability-observation code { display: block; font-size: 11px; overflow-wrap: anywhere; } .personal-capability-linked-setting { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; margin: 0 24px 20px; padding: 14px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); } .personal-capability-linked-setting > div { display: grid; gap: 4px; } .personal-capability-linked-controls { min-width: 0; justify-items: start; } diff --git a/loopx/capabilities/progress_review/context.py b/loopx/capabilities/progress_review/context.py index 2fa8e24d81..8f8eed1530 100644 --- a/loopx/capabilities/progress_review/context.py +++ b/loopx/capabilities/progress_review/context.py @@ -33,10 +33,44 @@ def external_progress_review_context( goal_id = str(goal.get("id") or "").strip() if policy["mode"] == "off" or runtime_root is None or not goal_id: return None + from ...control_plane.effect_runtime import effect_runtime_result, EffectRuntimeRemoteError + read_error = None try: loaded, rejected = load_progress_review_receipts(Path(runtime_root), goal_id) - except (OSError, ValueError): + except (OSError, ValueError, EffectRuntimeRemoteError): loaded, rejected = [], 0 + read_error = "receipt_store_unavailable" + # Only newly scoped canonical observations need this current-owner check. + # Old receipts and manual studies retain their existing policy semantics. + current_by_todo = {} + qualified = [] + for receipt in loaded: + binding = receipt.get("evidence_scope", {}).get("criterion_binding", {}) + if binding.get("origin") == "goal_acceptance": + todo_id = binding["todo_id"] + if todo_id not in current_by_todo: + try: + observation = effect_runtime_result("goal.acceptance.inspect", { + "goal_id": goal_id, "runtime_root": str(runtime_root), "todo_id": todo_id, + }) + current_by_todo[todo_id] = observation.get("completion_requirements") + except (OSError, ValueError, EffectRuntimeRemoteError): + current_by_todo[todo_id] = None + try: + requirements = current_by_todo[todo_id] + selected = effect_runtime_result("progress_review.criterion_basis", { + "requirements": requirements, "criterion_ids": binding["criterion_ids"], "acceptance": [], + "goal_id": goal_id, "agent_id": receipt["run"]["agent_id"], + })["binding"] if requirements is not None else None + current = selected == binding + except (ValueError, EffectRuntimeRemoteError): + current = False + receipt = {**receipt, "criterion_current": current} + if not current: + receipt.update(status="stale", reason="criterion_basis_unavailable", + drift_signal={"noul": None, "choice": None}) + qualified.append(receipt) + loaded = qualified pinned = policy.get("contract_revision") # Newest by run order, never by the observer's local sequence counter. newest = max(loaded, key=progress_review_receipt_order_key, default=None) @@ -46,10 +80,10 @@ def external_progress_review_context( # pin is manual, so a newer receipt under another revision means the # observer basis moved and the Goal owner must re-pin (or not). receipts = [item for item in loaded if item["contract_revision"] == pinned] - stale = len(loaded) - len(receipts) + stale = len(loaded) - len(receipts) + sum(item.get("criterion_current") is False for item in receipts) else: - receipts, stale = loaded, 0 - return { + receipts, stale = loaded, sum(item.get("criterion_current") is False for item in loaded) + result = { "policy": policy, "receipts": receipts, "summary": progress_review_receipt_summary( @@ -60,6 +94,10 @@ def external_progress_review_context( newest_contract_revision=newest_revision, ), } + result["summary"]["read_state"] = "unavailable" if read_error else "observed" if loaded else "missing" + if read_error: + result["summary"]["read_error"] = read_error + return result __all__ = ["external_progress_review_context"] diff --git a/loopx/capabilities/progress_review/receipt.py b/loopx/capabilities/progress_review/receipt.py index 336abe35ee..c3706e3c7f 100644 --- a/loopx/capabilities/progress_review/receipt.py +++ b/loopx/capabilities/progress_review/receipt.py @@ -267,6 +267,14 @@ def normalize_progress_review_receipt(value: Any) -> dict[str, Any]: or float(recorded_at) < 0 ): raise TypeError("receipt.recorded_at must be a non-negative epoch number") + scope = None + if "evidence_scope" in value: + from ...control_plane.effect_runtime import effect_runtime_result, EffectRuntimeRejected + try: + scope = effect_runtime_result("progress_review.evidence_scope", {"scope": value["evidence_scope"], + "goal_id": value.get("goal_id"), "run": value.get("run")})["scope"] + except EffectRuntimeRejected as exc: + raise ValueError("invalid progress-review evidence scope") from exc event_id = _hex64(value.get("event_id"), field="event_id") return { "schema_version": PROGRESS_REVIEW_RECEIPT_SCHEMA_VERSION, @@ -291,6 +299,7 @@ def normalize_progress_review_receipt(value: Any) -> dict[str, Any]: "usage": usage, "recorded_at": float(recorded_at), "authority": "none", + **({"evidence_scope": scope} if scope is not None else {}), } @@ -426,6 +435,8 @@ def progress_review_receipt_summary( "drift_signal": dict(receipt["drift_signal"]), "model": receipt["model"], "question_version": receipt["question_version"], + **({"evidence_scope": receipt["evidence_scope"]} if "evidence_scope" in receipt else {}), + **({"criterion_current": receipt["criterion_current"]} if "criterion_current" in receipt else {}), } summary: dict[str, Any] = { "schema_version": "progress_review_status_v0", diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index bd65e9b87a..0367e6c164 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -417,6 +417,14 @@ def _goal_configuration_inspect(self) -> None: result, machine_namespaces=self._goal_configuration_machine_namespaces(), ) + from .agent_registry import load_goal_from_registry + from .capabilities.progress_review.context import external_progress_review_context + goal = load_goal_from_registry(self.server.registry_path, goal_ids[0].strip()) + observation = external_progress_review_context(goal or {}, getattr(self.server, "runtime_root", None)) + if observation is not None: + for capability in response.get("capability_catalog", {}).get("capabilities", []): + if capability.get("capability_id") == "progress_review": + capability["observation"] = observation["summary"] except (FileNotFoundError, KeyError, TypeError, ValueError) as exc: self._send_error( str(exc), diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index beb2f767ae..da920408e9 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -605,6 +605,8 @@ export function createEffectRuntimeHandlers( ["collaboration.delegation.observe_wake", lazyHandler(() => import("./collaboration/delegation.ts"), ({decideDelegationWakeObservation}) => decideDelegationWakeObservation)], ["collaboration.delegation.recover_validated_settlement", lazyHandler(() => import("./collaboration/delegation.ts"), ({recoverValidatedDelegationSettlement}) => recoverValidatedDelegationSettlement)], ["collaboration.delegation.stop", lazyHandler(() => import("./collaboration/delegation.ts"), ({decideDelegationStop}) => decideDelegationStop)], + ["progress_review.evidence_scope", lazyHandler(() => import("./work_items/progress_review_evidence.ts"), ({progressReviewEvidenceScope}) => progressReviewEvidenceScope)], + ["progress_review.criterion_basis", lazyHandler(() => import("./work_items/progress_review_evidence.ts"), ({progressReviewCriterionBasis}) => progressReviewCriterionBasis)], ["collaboration.delegation.adoption", lazyHandler(() => import("./collaboration/delegation.ts"), ({recordDelegationAdoption}) => recordDelegationAdoption)], [ "collaboration.request.normalize", diff --git a/loopx/control_plane/work_items/external_progress_review.py b/loopx/control_plane/work_items/external_progress_review.py index b32fb21806..1328ac0698 100644 --- a/loopx/control_plane/work_items/external_progress_review.py +++ b/loopx/control_plane/work_items/external_progress_review.py @@ -169,7 +169,7 @@ def _verdict( signal: str, pinned: str, ) -> Verdict: - """Classify one captured transition as drift, on_goal or unevaluated.""" + """Classify one captured transition as drift, condition_not_met or unevaluated.""" if receipt is None: return "unevaluated", "unattributed" if ambiguous else "missing" @@ -192,7 +192,7 @@ def _verdict( if value is True: return "drift", None if value is False: - return "on_goal", None + return "condition_not_met", None return "unevaluated", "undecided" @@ -272,7 +272,7 @@ def external_progress_review_trigger( newer `completed` receipt whose selected signal is False: the policy's condition no longer holds, so nothing is open; - every transition is `drift` (completed, signal True, pinned revision), - `on_goal` (completed, signal False) or `unevaluated` for one typed + `condition_not_met` (completed, signal False) or `unevaluated` for one typed reason in EXTERNAL_PROGRESS_REVIEW_UNEVALUATED_REASONS; - a streak forms only from `threshold` consecutive drift transitions with no unevaluated transition between them (conservative formation); @@ -343,7 +343,7 @@ def external_progress_review_trigger( verdict, reason = _verdict( run, receipt, ambiguous=ambiguous, signal=signal, pinned=pinned ) - if verdict == "on_goal": + if verdict == "condition_not_met": break if verdict == "drift": assert receipt is not None diff --git a/loopx/control_plane/work_items/progress_review_evidence.ts b/loopx/control_plane/work_items/progress_review_evidence.ts new file mode 100644 index 0000000000..f1ebc827ee --- /dev/null +++ b/loopx/control_plane/work_items/progress_review_evidence.ts @@ -0,0 +1,67 @@ +/** Criterion selection from authorized current facts; observation grants no authority. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +export function progressReviewCriterionBasis(params: JsonObject): JsonObject { + function requireThat(condition: unknown, message: string): asserts condition { + if (!condition) throw new EffectRuntimeRequestError(message); + } + const requested = params.criterion_ids; + if (params.requirements === null) { + requireThat(Array.isArray(params.acceptance) && params.acceptance.length > 0 && params.acceptance.length <= 128 + && params.acceptance.every(row => typeof row === "string" && row.length > 0 && row.length <= 32768), + "bounded operator study criteria required"); + return {acceptance: params.acceptance, binding: {origin: "operator_study", + criteria_sha256: canonicalAuthoritySha256(params.acceptance)}}; + } + const basis = requireJsonObject(params.requirements, "current acceptance requirements"); + requireThat(Array.isArray(requested) && requested.length > 0 && requested.length <= 128 + && requested.every(row => typeof row === "string" && row.length > 0) + && new Set(requested).size === requested.length && Array.isArray(basis.criteria), + "explicit current criterion selection required"); + const criteria = basis.criteria.map(raw => requireJsonObject(raw, "canonical acceptance criterion")) + .filter(row => requested.includes(row.id)); + requireThat(criteria.length === requested.length && new Set(criteria.map(row => row.id)).size === criteria.length, + "selected criteria must belong to this current task acceptance scope"); + requireThat(criteria.every(row => typeof row.description === "string" && row.description.length > 0 + && row.description.length <= 32768), "bounded canonical criterion descriptions required"); + requireThat(typeof basis.contract_digest === "string" && Number.isInteger(basis.contract_revision) + && typeof basis.todo_id === "string" && typeof basis.todo_semantic_digest === "string", + "current canonical acceptance identity required"); + requireThat(typeof params.goal_id === "string" && params.goal_id.length > 0 + && typeof params.agent_id === "string" && params.agent_id.length > 0, "canonical goal and agent identity required"); + return {acceptance: criteria.map(row => row.description), binding: {origin: "goal_acceptance", + goal_id: params.goal_id, agent_id: params.agent_id, + todo_id: basis.todo_id, contract_digest: basis.contract_digest, contract_revision: basis.contract_revision, + todo_semantic_digest: basis.todo_semantic_digest, criterion_ids: criteria.map(row => row.id), + criteria_sha256: canonicalAuthoritySha256(criteria)}}; +} + +/** Decode path-free observation metadata; this does not verify model answers. */ +export function progressReviewEvidenceScope(params: JsonObject): JsonObject { + const scope = requireJsonObject(params.scope, "progress-review evidence scope"); + const binding = requireJsonObject(scope.criterion_binding, "progress-review criterion binding"); + const fail = (ok: boolean) => {if (!ok) throw new EffectRuntimeRequestError("invalid bounded progress-review evidence scope");}; + fail(scope.coverage === "declared_file_net_change" && Array.isArray(scope.files) && scope.files.length <= 32 + && scope.files.every(ref => typeof ref === "string" && ref.length > 0 && ref.length <= 1024 + && !ref.startsWith("/") && !ref.includes("\\") && !ref.split("/").includes(".."))); + fail(binding.origin === "operator_study" || binding.origin === "goal_acceptance"); + fail(typeof binding.criteria_sha256 === "string" && /^[a-f0-9]{64}$/.test(binding.criteria_sha256)); + const normalized: JsonObject = {origin: binding.origin, criteria_sha256: binding.criteria_sha256}; + if (binding.origin === "goal_acceptance") { + fail(typeof binding.goal_id === "string" && binding.goal_id.length > 0 && binding.goal_id.length <= 200 + && typeof binding.agent_id === "string" && binding.agent_id.length > 0 && binding.agent_id.length <= 200 + && typeof binding.todo_id === "string" && binding.todo_id.length > 0 && binding.todo_id.length <= 200 + && typeof binding.contract_digest === "string" && /^[a-f0-9]{64}$/.test(binding.contract_digest) + && Number.isInteger(binding.contract_revision) && Number(binding.contract_revision) >= 1 + && typeof binding.todo_semantic_digest === "string" && /^[a-f0-9]{64}$/.test(binding.todo_semantic_digest) + && Array.isArray(binding.criterion_ids) && binding.criterion_ids.length > 0 && binding.criterion_ids.length <= 128 + && binding.criterion_ids.every(id => typeof id === "string" && id.length > 0 && id.length <= 200)); + const run = requireJsonObject(params.run, "criterion-bound run identity"); + fail(params.goal_id === binding.goal_id && run.agent_id === binding.agent_id && run.todo_id === binding.todo_id); + for (const key of ["goal_id", "agent_id", "todo_id", "contract_digest", "contract_revision", "todo_semantic_digest", "criterion_ids"]) + normalized[key] = binding[key]; + } + return {scope: {criterion_binding: normalized, coverage: scope.coverage, files: scope.files}}; +} diff --git a/packages/loopx-jev/src/loopx_jev/drift.py b/packages/loopx-jev/src/loopx_jev/drift.py index 7127bb6c76..2daac2d7cc 100644 --- a/packages/loopx-jev/src/loopx_jev/drift.py +++ b/packages/loopx-jev/src/loopx_jev/drift.py @@ -69,6 +69,8 @@ def contract(path: Path, repo: Path) -> tuple[dict[str, Any], str, Callable[[], if not isinstance(basis.get("goal_id"), str) or not basis["goal_id"].strip(): raise ValueError("missing_goal_identity") _, revision = read_json(path, 32768) + if basis.get("criterion_binding", {}).get("origin") == "goal_acceptance": + revision = digest({"manifest": revision, "criterion_binding": basis["criterion_binding"]}) return basis, revision, guard @@ -170,6 +172,20 @@ def invalidate_baseline(root: Path) -> None: atomic_json(root / "state.json", current) +def _require_criterion_run(basis, goal_id, run, paths): + # The core decoder owns this identity rule for both capture and receipt use. + if basis.get("criterion_binding", {}).get("origin") == "goal_acceptance": + from loopx.control_plane.effect_runtime import effect_runtime_result, EffectRuntimeRejected + try: + effect_runtime_result("progress_review.evidence_scope", { + "goal_id": goal_id, "run": run, + "scope": {"criterion_binding": basis["criterion_binding"], + "coverage": "declared_file_net_change", "files": paths}, + }) + except EffectRuntimeRejected as exc: + raise ValueError("run_acceptance_scope_mismatch") from exc + + def enqueue( root: Path, prepared: dict[str, Any], @@ -186,6 +202,7 @@ def enqueue( "generated_at" ): raise ValueError("run_goal_or_identity_mismatch") + _require_criterion_run(prepared["basis"], current["goal_id"], record, current["paths"]) # Checkpoint supplements for the same bound Turn are the same transition. identity = ( { @@ -354,19 +371,27 @@ def drain( def current() -> bool: try: + _require_criterion_run(job["basis"], initial["goal_id"], job["run"], job["paths"]) now = policy(config_path) + # Legacy studies keep their manifest-only guard. New canonical + # scopes must recheck the same complete owner-bound revision. + if job.get("basis", {}).get("criterion_binding", {}).get("origin") == "goal_acceptance": + _, revision, basis_current = contract(Path(initial["basis_path"]), Path(initial["repo"])) + if not basis_current(): + return False + else: + _, revision = read_json(Path(initial["basis_path"]), 32768) return ( now.mode == "shadow" and now.generation == job["config_generation"] and state(root)["configuration_epoch"] == job["configuration_epoch"] - and read_json(Path(initial["basis_path"]), 32768)[1] - == job["contract_revision"] + and revision == job["contract_revision"] and read_json(job_path, 256 * 1024)[1] == job_digest and read_json(Path(job["source_record"]), 4 * 1024 * 1024)[1] == job["record_digest"] ) - except (OSError, ValueError, KeyError, TypeError): + except (OSError, ValueError, KeyError, TypeError, RuntimeError): return False options: dict[str, Any] = {} @@ -472,6 +497,9 @@ def _emit_receipt( "timing_ns": timing, "usage": result.get("usage") if isinstance(result.get("usage"), dict) else None, "recorded_at": time.time(), + **({"evidence_scope": {"criterion_binding": job["basis"]["criterion_binding"], + "coverage": "declared_file_net_change", "files": job.get("paths", [])}} + if (job.get("basis") or {}).get("criterion_binding") else {}), } try: path = write_progress_review_receipt(runtime_root, goal_id, receipt) diff --git a/packages/loopx-jev/src/loopx_jev/runner.py b/packages/loopx-jev/src/loopx_jev/runner.py index 8d86ee6c9f..9810552c92 100644 --- a/packages/loopx-jev/src/loopx_jev/runner.py +++ b/packages/loopx-jev/src/loopx_jev/runner.py @@ -31,6 +31,7 @@ def read_basis( "horizon", "evidence", "already_known", + "acceptance_scope", } if not isinstance(manifest, dict) or set(manifest) - allowed: raise ValueError("unknown basis fields") @@ -85,16 +86,53 @@ def read_basis( } ) checks.append((target, digest)) + from loopx.control_plane.effect_runtime import effect_runtime_result + from loopx.control_plane.goals.acceptance import inspect_goal_acceptance + + scope = manifest.get("acceptance_scope") + def criterion_basis(): + requirements = None + requested = None + if scope is not None: + if not isinstance(scope, dict) or set(scope) != { + "registry_ref", "runtime_ref", "agent_id", "todo_id", "criterion_ids" + }: + raise ValueError("explicit current task acceptance scope required") + def local_reference(key): + value = scope[key] + if not isinstance(value, str) or not value: + raise ValueError("local acceptance reference required") + relative = Path(value) + target = workspace / relative + if relative.is_absolute() or ".." in relative.parts or target.is_symlink() or not target.resolve().is_relative_to(workspace.resolve()): + raise ValueError("acceptance reference escapes the selected workspace") + return target + current = inspect_goal_acceptance(registry_path=local_reference("registry_ref"), + runtime_root=str(local_reference("runtime_ref")), goal_id=manifest.get("goal_id"), + agent_id=scope["agent_id"], todo_id=scope["todo_id"]) + requirements = current.get("completion_requirements") + if requirements is None: + raise ValueError("current task has no canonical acceptance criteria") + requested = scope["criterion_ids"] + return effect_runtime_result("progress_review.criterion_basis", { + "acceptance": manifest["acceptance"], "requirements": requirements, "criterion_ids": requested, + "goal_id": manifest.get("goal_id"), "agent_id": scope.get("agent_id") if scope else None, + }) + selected = criterion_basis() basis = { **manifest, "evidence": observations, "basis_origin": "explicit_operator_study_basis_not_completion_authority", + "acceptance": selected["acceptance"], + "criterion_binding": selected["binding"], } def current() -> bool: try: if read_json(path, 32768)[1] != manifest_hash: return False + if scope is not None and criterion_basis() != selected: + return False for target, digest in checks: if target.is_symlink() or not target.resolve().is_relative_to( workspace.resolve() @@ -105,7 +143,7 @@ def current() -> bool: if len(raw) > 32768 or hashlib.sha256(raw).hexdigest() != digest: return False return True - except (OSError, ValueError): + except (OSError, ValueError, RuntimeError): return False return basis, current From 3a9e308f5949ca6e34aec3f41ade78e11c09f610 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 10 Oct 2026 10:58:57 +0800 Subject: [PATCH 2/6] docs: qualify criterion-bound review Signed-off-by: song --- .../agent-failure-containment-and-recovery.md | 3 +- ...-failure-containment-and-recovery.zh-CN.md | 2 +- ...state-machines-recovery-verification-v0.md | 17 +++++--- ...machines-recovery-verification-v0.zh-CN.md | 14 +++++-- .../optional-semantic-assistance-jev-v0.md | 39 +++++++++++-------- ...tional-semantic-assistance-jev-v0.zh-CN.md | 30 ++++++++------ loopx/capabilities/progress_review/README.md | 29 ++++++++++++-- .../progress_review/README.zh-CN.md | 22 +++++++++-- packages/loopx-jev/DRIFT_SHADOW.md | 36 ++++++++++++++++- packages/loopx-jev/DRIFT_SHADOW.zh-CN.md | 27 +++++++++++++ 10 files changed, 170 insertions(+), 49 deletions(-) diff --git a/docs/architecture/agent-failure-containment-and-recovery.md b/docs/architecture/agent-failure-containment-and-recovery.md index 307e792287..fbd7ae43c5 100644 --- a/docs/architecture/agent-failure-containment-and-recovery.md +++ b/docs/architecture/agent-failure-containment-and-recovery.md @@ -197,8 +197,7 @@ contracts; passing them does not establish live model correction, arbitrary remote exactly-once effects or production recovery time. Diagnostics deployment, overhead and long-running non-interference need separate evidence. -The RFCs specify three remaining integration gaps in these partially implemented -paths: +The RFCs track three integration paths with separate implementation checkpoints: 1. [Current-use checks for explicit delegation ancestry](rfcs/shared-goal-alignment-and-governed-amendment-v0.md#38-invalid-evidence-and-affected-consumers): refuse new dependent use when its declared basis is unavailable, preserving diff --git a/docs/architecture/agent-failure-containment-and-recovery.zh-CN.md b/docs/architecture/agent-failure-containment-and-recovery.zh-CN.md index 26c4017824..fabc6a84c2 100644 --- a/docs/architecture/agent-failure-containment-and-recovery.zh-CN.md +++ b/docs/architecture/agent-failure-containment-and-recovery.zh-CN.md @@ -88,7 +88,7 @@ Note 能保留尝试过的路径与下一步,但 summary 仍由发送者提供 这些测试设计包含失败、陈旧来源、输入改变和 effect 中断等负例。Fixture provider 与一次性本地状态验证各自合同;通过不等于真实模型纠错、任意远端 exactly-once 或生产恢复时间合格。诊断部署、开销与长时间 non-interference 需要独立证据。 -RFC 明确了这些部分实现路径中仍需补齐的三项整合缺口: +RFC 将以下三项整合路径分别记录为独立 checkpoint: 1. [显式委派来源链的当前使用检查](rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md#38-失效证据与受影响消费者):声明依据不可用时拒绝新的依赖使用,保留历史完成,限制遍历与验证成本。 2. [原任务修复与复验](rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md#基于源码的实现顺序):把独立失败依据传入可操作恢复路径,按当前 criterion 复验,再对账原效果。 diff --git a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md index 2b68db8414..1da8ad873e 100644 --- a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md +++ b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md @@ -304,15 +304,13 @@ lease/GoalRef fence, followed by M3's actual delivery callers. ### Source-grounded implementation sequence -These are unimplemented composition deltas within existing M2/M3 and roadmap -R2/R3/R4, not new milestones. Source owners were rechecked at -`44931b6d22a50b949d43354e6ea498fb6b68d231`. +The table tracks independent composition boundaries within M2/M3 and roadmap R2/R3/R4. Candidate checkpoints do not establish installed behavior or full M2 acceptance. The source baseline is `233cc76fd22760947d73e1501032b8b77e28148b`. -| Bounded outcome | Existing entry and owner | Current gap and decisive exit | +| Bounded outcome | Existing entry and owner | Current checkpoint and decisive exit | | --- | --- | --- | | Unavailable declared ancestry prevents new dependent use | `Delegations._read_current/start`, `delegation_results.require_dependencies/adoption_evidence`, `delegation.ts` | Alignment §3.8: source → A → B with A input invalidated must refuse current dependent use even if A output is unchanged. Qualify real read/start/adopt/settlement and packaged evidence readback; keep historical completion. | | Independent check failure reaches an actionable original-task recovery journey | `executor._task_validation_stage`, `ValidatedTurnReceipt`, canonical `turn_loop_controller_contract_v0.json`, `turn_journal.ts` | Preserve qualified failure scope and repair/replan detail; complete bounded repair or verifier-only retry, current validation and original-effect settlement. Host assertions cannot become trusted validation. | -| Optional semantic review explains its evidence and coverage | Existing progress-review receipt/loader and canonical Goal acceptance inspection | Bind a selected criterion and evidence coverage in shadow readback; show missing/stale basis and separate judgment dimensions. Model quality and intervention qualify separately. | +| Shadow review explains criteria and coverage | progress-review receipt/context, `progress_review_evidence.ts`, canonical acceptance inspect | Select current canonical task criteria; changed task bindings withdraw old judgments. Manual studies and legacy receipts retain distinct strength. Show separate dimensions, declared-file net change, missing evidence and unreadable storage. Default off and existing assist trigger rules retain their semantics. | For the second outcome, `_task_validation_stage` already saves the independent result and blocks settlement. `ValidatedTurnReceipt` omits that validation's @@ -345,6 +343,15 @@ complete the journey. Bound repeated verification and source-chain traversal using measured workloads. Roll back code through its owner while retaining receipts, completed effects and unresolved recovery obligations. +An optional `acceptance_scope` refers only to registry/runtime paths inside the +selected workspace. Criteria come from the current owner; private commands and +paths stay out of model questions. Hashes, versions and declared coverage are +observations, not proof of model correctness, a durable business checkpoint or +whole-task completion. Existing team evidence and capability settings carry App +readback, and CLI/MCP share the owner; no Lark-specific protocol is introduced. +Live correction, remote exactly-once, takeover, observer quality and intervention +retain their existing RFC acceptance owners. + ## 12. Open decisions The first M2 implementation must choose its smallest sufficient exploration diff --git a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md index 95bf06b563..972be4f5b7 100644 --- a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md +++ b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md @@ -249,14 +249,13 @@ PostgreSQL authority、successor 调度和 App/Lark 送达尚未覆盖。因此 ### 基于源码的实现顺序 -以下是既有 M2/M3 与路线图 R2/R3/R4 中尚未实现的组合增量,不新增里程碑。 -Source owner 在 `44931b6d22a50b949d43354e6ea498fb6b68d231` 复核。 +以下表格记录既有 M2/M3 与路线图 R2/R3/R4 的独立组合边界;候选 checkpoint 不表示已安装能力或完整 M2 验收。源码基线为 `233cc76fd22760947d73e1501032b8b77e28148b`。 -| 有界结果 | 既有入口与 owner | 当前缺口与决定性出口 | +| 有界结果 | 既有入口与 owner | 当前 checkpoint 与决定性出口 | | --- | --- | --- | | 声明来源链不可用时,阻止新的依赖使用 | `Delegations._read_current/start`、`delegation_results.require_dependencies/adoption_evidence`、`delegation.ts` | 遵循对齐 §3.8:source → A → B 中 A input 失效,即使 A output 不变也须拒绝当前依赖使用;验证真实 read/start/adopt/settlement 和打包证据读回,保留历史完成。 | | 独立检查失败进入原任务可操作的恢复旅程 | `executor._task_validation_stage`、`ValidatedTurnReceipt`、canonical `turn_loop_controller_contract_v0.json`、`turn_journal.ts` | 传递已资格化失败范围与 repair/replan 细节;完成有界修复或只运行 verifier 的重试、当前验证与原效果结算。Host 声明不能冒充可信验证。 | -| 可选语义审查说明证据和覆盖范围 | 既有 progress-review receipt/loader 与 canonical Goal acceptance inspect | 在 shadow 读回绑定选中 criterion 和证据覆盖;显示缺失/陈旧依据及独立判断维度。模型质量和干预另行资格化。 | +| shadow 说明条款及观察范围 | progress-review receipt/context、`progress_review_evidence.ts`、canonical acceptance inspect | 可选择任务当前规范 criterion;任务关联变化后撤回旧判断。手工研究和旧回执保持不同证据强度;显示独立维度、净文件变化覆盖、缺失及存储未知。默认 off 和 assist 既有触发规则保持不变。 | 第二项中,`_task_validation_stage` 已保存独立结果并阻断结算;`ValidatedTurnReceipt` 未携带该验证的 `recovery_kind`,canonical controller 有意将 legacy @@ -280,6 +279,13 @@ result,只重跑 validation,不再次调用 Host。**实际修复工作**需 复制命令按钮或后端 receipt 不能独自完成旅程。用实际测量限制重复验证和来源链遍历 成本;经既有 owner 回退代码,同时保留 receipt、已提交效果和未解决恢复义务。 +规范审查 basis 的 `acceptance_scope` 只引用选定 workspace 内的 registry/runtime, +条款内容从当前 owner 读取,原命令及私有路径不进入模型问题。criterion hash、 +版本和覆盖是来源观察,不证明模型答案正确、完整业务 checkpoint 或全部任务完成。 +前端沿既有团队证据和能力设置入口读回;受影响 CLI/MCP 共用 owner,未新增 +Lark 专有协议。真实模型纠错、远端 exactly-once、接管、observer 质量与受控干预 +仍由原 RFC 验收所有者负责。 + ## 12. 未决事项 首个 M2 实现由测试与领域维护者选择足够小的探索方法及边界。优先复用 fixture diff --git a/docs/architecture/rfcs/optional-semantic-assistance-jev-v0.md b/docs/architecture/rfcs/optional-semantic-assistance-jev-v0.md index 3d4af87063..2f721a9d93 100644 --- a/docs/architecture/rfcs/optional-semantic-assistance-jev-v0.md +++ b/docs/architecture/rfcs/optional-semantic-assistance-jev-v0.md @@ -238,24 +238,29 @@ delay, false intervention against legitimate work and missed failures. Sample cases that never generated an alert as well as alerted cases; alert-only review cannot estimate missed failures. -Source recheck at `44931b6d22a50b949d43354e6ea498fb6b68d231`: -the optional `packages/loopx-jev` capture reads a declared file set and its net -change; it does not observe every intermediate assertion. Core -[`external_progress_review_trigger`](../../../loopx/control_plane/work_items/external_progress_review.py) -consumes attributable receipts at a pinned assessment revision. In `assist`, a -qualified streak can open the existing replan obligation; a newer completed -non-triggering verdict can end that streak. This is the current signal policy, -not a business-correctness certificate. The -[`progress-review` contract](../../../loopx/capabilities/progress_review/README.md) -and its owner retain off/shadow/assist compatibility. Any change to the combined -signal or its clearing policy needs explicit disclosure and counterexamples; -this design does not silently change it. - -The downstream contract is [evidence invalidation in shared work](shared-goal-alignment-and-governed-amendment-v0.md#38-invalid-evidence-and-affected-consumers) +The local candidate now binds an explicitly selected canonical task criterion +through [`progress_review_evidence.ts`](../../../loopx/control_plane/work_items/progress_review_evidence.ts). +The optional observer's `acceptance_scope` reads the existing acceptance owner; +private registry/runtime references stay outside the model packet. A manual +basis remains an `operator_study`. Receipts declare selected-file net-change +coverage. Capture and core receipt decoding require the canonical Goal/Agent/Todo +scope to match the actual run; mismatches never reach model dispatch or drift +consumption. Core readback also rechecks bindings against current criteria. A changed or unavailable basis withdraws that receipt's signal without +rewriting its history. Legacy receipt handling and default-off isolation remain. + +The packaged capability editor separately displays relationship and increment, +criterion origin, coverage, stale/missing/unavailable states and an explicit +refresh. `off_goal` plus `new_evidence` does not become an on-Goal verdict; a +non-triggering combined signal retains the existing streak behavior. File/SQLite +canonical criterion edits and the production HTTP/frontend journey have local +validation; no new live model-quality experiment or automatic intervention was +performed. See the [capability contract](../../../loopx/capabilities/progress_review/README.md) +and [observer guide](../../../packages/loopx-jev/DRIFT_SHADOW.md). + +These observations connect to [shared evidence invalidation](shared-goal-alignment-and-governed-amendment-v0.md#38-invalid-evidence-and-affected-consumers) and [recovery qualification](composable-state-machines-recovery-verification-v0.md#detection-containment-and-recovery-evidence). -First connect existing verifier results and dependency readback to those owners. -Additional model inference is conditional on the comparison in section 6; it is -not a prerequisite for fixing a demonstrated deterministic gap. +Model reasoning remains subject to the comparisons in section 6 and is not a +prerequisite for repairing an evidenced deterministic gap. ### 5.3 Provider facts and deferred mechanisms diff --git a/docs/architecture/rfcs/optional-semantic-assistance-jev-v0.zh-CN.md b/docs/architecture/rfcs/optional-semantic-assistance-jev-v0.zh-CN.md index 286e7354d2..1c14b45a14 100644 --- a/docs/architecture/rfcs/optional-semantic-assistance-jev-v0.zh-CN.md +++ b/docs/architecture/rfcs/optional-semantic-assistance-jev-v0.zh-CN.md @@ -224,19 +224,25 @@ D1 分别研究目标关系与证据增量。保留必要前置、合理阻塞 行为。可选评估不可用时,必要的确定性检查仍生效。除检测延迟外,还要测量对合法 工作的误干预与漏报。告警和从未告警的记录都要抽查;只审查告警记录无法估计漏报。 -源码在 `44931b6d22a50b949d43354e6ea498fb6b68d231` 复核:可选 -`packages/loopx-jev` capture 读取声明文件集合及净变化,不观察每条中间断言。 -核心 [`external_progress_review_trigger`](../../../loopx/control_plane/work_items/external_progress_review.py) -消费身份可归属、评估版本已固定的回执。`assist` 可把合格连续信号转为既有 replan -义务;较新的已完成、未触发 verdict 可以结束该连续信号。这是当前信号政策,不是 -业务正确证明。[progress-review 合同](../../../loopx/capabilities/progress_review/README.zh-CN.md) -继续拥有 off/shadow/assist 兼容性。修改组合信号或解除政策须明确披露并提供反例, -本设计不隐式改变当前行为。 - -后续连接[共享工作中的证据失效](shared-goal-alignment-and-governed-amendment-v0.zh-CN.md#38-失效证据与受影响消费者) +本地候选实现通过 +[`progress_review_evidence.ts`](../../../loopx/control_plane/work_items/progress_review_evidence.ts) +绑定显式选中的规范任务 criterion。可选观察器的 `acceptance_scope` 读取既有验收 +owner;私有 registry/runtime 引用不进入模型请求。手写 basis 仍为 `operator_study`。 +回执声明选中文件的净变化覆盖;采集与核心回执解码共用 Goal/Agent/Todo 身份约束, +不匹配时拒绝模型派发及漂移消费。核心读回也把新 canonical binding 与当前 criterion +复核;依据变化或不可用时撤回该回执信号,不改历史。旧回执兼容性与默认关闭隔离 +保持原合同。 + +打包能力编辑器分别展示目标关系与证据增量、criterion 来源、覆盖范围及过期/缺失/ +不可用状态,提供显式刷新。`off_goal` 加 `new_evidence` 不被改写为符合目标;组合 +信号未触发仍遵循原连续段政策。File/SQLite 下规范 criterion 编辑及真实 HTTP/界面 +流程已做本地验证;未开展新的真实模型质量实验或自动干预。见 +[能力合同](../../../loopx/capabilities/progress_review/README.zh-CN.md)与 +[观察器指南](../../../packages/loopx-jev/DRIFT_SHADOW.zh-CN.md)。 + +这些观察衔接[共享工作中的证据失效](shared-goal-alignment-and-governed-amendment-v0.zh-CN.md#38-失效证据与受影响消费者) 与[恢复资格](composable-state-machines-recovery-verification-v0.zh-CN.md#检测控制与恢复证据)。 -优先把现有 verifier 结果与依赖读回接到这些 owner;增加模型推理仍取决于第 6 节 -比较,不能成为修复已证实确定性缺口的前置条件。 +增加模型推理仍取决于第 6 节比较,不能成为修复已证实确定性缺口的前置条件。 ### 5.3 提供方事实与延后机制 diff --git a/loopx/capabilities/progress_review/README.md b/loopx/capabilities/progress_review/README.md index 0f2d6f5650..6d25063e3f 100644 --- a/loopx/capabilities/progress_review/README.md +++ b/loopx/capabilities/progress_review/README.md @@ -44,7 +44,7 @@ loopx configure-goal --goal-id --clear-progress-review-configuration - | `mode` | `off`, `shadow`, `assist` | `off` loads nothing; `shadow` records and displays; `assist` may raise the obligation | | `signal` | `noul`, `choice` | Which receipt judgment pair counts as drift | | `drift_threshold` | 2–20 | Consecutive completed drift receipts before an obligation | -| `contract_revision` | sha256 or empty | The observer basis revision receipts must be bound to; printed by `loopx-jev drift init`. Required for `assist`. The pin is manual: receipts bound to other revisions are never counted, changing the basis does not retire earlier receipts by itself, and status reports `rebind_hint: newer_receipts_under_unpinned_revision` when the newest receipt is bound elsewhere | +| `contract_revision` | sha256 or empty | The observer basis revision receipts must be bound to; printed by `loopx-jev drift init`. Required for `assist`. The pin is manual: receipts bound to other revisions are never counted, changing the basis does not retire legacy or operator-study receipts by itself, and status reports `rebind_hint: newer_receipts_under_unpinned_revision` when the newest receipt is bound elsewhere | The policy lives at `control_plane.progress_review` in the goal registry and is visible in `loopx configure-goal --goal-id ` under `feature_summary` @@ -65,7 +65,10 @@ Each receipt carries only typed fields: - `judgments.noul`: probabilities for `behavior_change`, `serves_acceptance`, `evidence_increment`, or null; - `drift_signal.noul` and `drift_signal.choice`: `true`, `false` or null; -- `timing_ns`, `usage`, `label_probability_threshold`, `recorded_at`. +- `timing_ns`, `usage`, `label_probability_threshold`, `recorded_at`; +- optional `evidence_scope`: criterion origin/version/hash and declared relative + files, with coverage `declared_file_net_change`. New canonical bindings are + rechecked against current task acceptance before their signals are consumed. `sequence` is the writing observer's local counter and restarts at zero when `drift init` creates a new observer state. The core never compares sequences @@ -105,7 +108,7 @@ This tightens the original stage-0 `assist` compatibility: incompletely bound historical receipts remain visible in shadow/status but cannot form or clear an obligation. Existing off behavior, model requests, manual revision pins and typed replan outcomes are unchanged. Every captured transition is one of three things: **drift** -(completed, selected signal `true`, pinned revision), **on-goal** (completed, +(completed, selected signal `true`, pinned revision), **condition not met** (completed, signal `false`) or **unevaluated** for one typed reason (`pending`, `failed`, `abstained`, `stale`, `undecided`, `missing`, `unattributed`, `identity_conflict`, `other_revision`, `not_evaluated`). Formation is @@ -113,12 +116,30 @@ conservative: an obligation needs `drift_threshold` consecutive drift transitions with no unevaluated transition between them. Persistence is not: once formed, newer unevaluated transitions neither extend nor dissolve the obligation, and their count is reported as `unevaluated_transitions` on the -trigger. Only an acknowledged replan or a newer completed on-goal verdict ends +trigger. Only an acknowledged replan or a newer completed non-triggering verdict ends it; the Goal owner can also set the mode back to `shadow` or `off`. Receipts bound to a revision other than the pinned one are unevaluated history and never counted. The scan covers the run history the Goal keeps (`latest_runs`), so an obligation can only be as old as that window. +## Criterion and observation readback + +For canonical task criteria, initialize the optional observer with an explicit +[`acceptance_scope`](../../../packages/loopx-jev/DRIFT_SHADOW.md#bind-canonical-task-criteria). +The observer reads the existing acceptance owner instead of trusting the +manifest's acceptance text. Manual bases remain labeled `operator_study`. +A changed or unavailable canonical criterion binding makes that receipt stale +and withdraws its signal; stored history and the policy's manual revision pin +remain intact. Legacy receipts retain their existing behavior. + +In **Goal settings → capability center → progress-review sentinel**, enabled +Goals show relationship and evidence increment separately, criterion origin, +selected-file coverage and missing/stale/unavailable states. Use **重新读取审查证据** +to refresh; the old judgment is withdrawn during the read. `off_goal` together +with `new_evidence` can be a non-triggering signal without being on Goal. +Receipt-store failure is unavailable, never cached success. Model scores do not +validate a task or authorize stop, rollback or completion. `off` reads nothing. + ## Discharge An `assist` obligation is discharged only the way every autonomous replan diff --git a/loopx/capabilities/progress_review/README.zh-CN.md b/loopx/capabilities/progress_review/README.zh-CN.md index 784384acbd..d340ffae42 100644 --- a/loopx/capabilities/progress_review/README.zh-CN.md +++ b/loopx/capabilities/progress_review/README.zh-CN.md @@ -35,7 +35,7 @@ loopx configure-goal --goal-id --clear-progress-review-configuration - | `mode` | `off`、`shadow`、`assist` | `off` 不加载任何内容;`shadow` 记录并展示;`assist` 可以触发义务 | | `signal` | `noul`、`choice` | 哪一组判断算作漂移 | | `drift_threshold` | 2–20 | 触发义务前需要的连续已完成漂移回执数 | -| `contract_revision` | sha256 或空 | 回执必须绑定的观察器 basis 修订,由 `loopx-jev drift init` 打印;`assist` 必需。pin 是手动的:绑定到其他修订的回执永不计数,basis 变化本身不会让早先回执退休,最新回执绑定到别处时 status 报告 `rebind_hint: newer_receipts_under_unpinned_revision` | +| `contract_revision` | sha256 或空 | 回执必须绑定的观察器 basis 修订,由 `loopx-jev drift init` 打印;`assist` 必需。pin 是手动的:绑定到其他修订的回执永不计数,basis 变化本身不会让旧格式或 operator-study 回执退休,最新回执绑定到别处时 status 报告 `rebind_hint: newer_receipts_under_unpinned_revision` | 策略保存在 Goal 注册表的 `control_plane.progress_review`,可在 `loopx configure-goal --goal-id ` 输出的 `feature_summary` 和 Dashboard 能力编辑器中看到。格式错误的配置块会安全地退回 `off`。 @@ -48,7 +48,9 @@ loopx configure-goal --goal-id --clear-progress-review-configuration - - `judgments.choice`:`relation` 与 `increment` 标签或 null; - `judgments.noul`:`behavior_change`、`serves_acceptance`、`evidence_increment` 的概率或 null; - `drift_signal.noul`、`drift_signal.choice`:`true`、`false` 或 null; -- `timing_ns`、`usage`、`label_probability_threshold`、`recorded_at`。 +- `timing_ns`、`usage`、`label_probability_threshold`、`recorded_at`; +- 可选 `evidence_scope`:criterion 来源/版本/摘要、声明的相对文件及 + `declared_file_net_change` 覆盖。新 canonical binding 的信号被消费前会与当前任务验收复核。 `sequence` 是写回执的观察器的本地计数,`drift init` 创建新的观察器状态时会从 0 重新计数。核心从不跨观察器比较 sequence:加载、加载上限、最新修订判断和同一转换两条回执的合并,都按 run 的 `generated_at`、再 `recorded_at`、再 `sequence` 排序(`progress_review_receipt_order_key`)。 @@ -63,7 +65,21 @@ loopx configure-goal --goal-id --clear-progress-review-configuration - 这收紧了原 stage-0 `assist` 的兼容行为:身份不完整的历史回执仍可在 shadow/status 查看,但不能形成或解除义务。off 行为、模型请求、手动 revision pin 与类型化 replan 出口保持原契约。 -每个被捕获的转换只有三种:**漂移**(completed、所选信号为 `true`、pin 修订)、**on-goal**(completed、信号为 `false`)或**未评估**,后者带一个类型化原因(`pending`、`failed`、`abstained`、`stale`、`undecided`、`missing`、`unattributed`、`identity_conflict`、`other_revision`、`not_evaluated`)。形成是保守的:义务需要 `drift_threshold` 个连续漂移转换,中间不能有未评估转换。存续则不然:一旦形成,更新的未评估转换既不延长也不解除义务,其数量作为 `unevaluated_transitions` 记在 trigger 上。只有已确认的重规划或更新的 completed on-goal 判定能结束它;Goal 负责人也可以把模式调回 `shadow` 或 `off`。绑定到非 pin 修订的回执是未评估的历史,永不计数。扫描范围是 Goal 保留的 run 历史(`latest_runs`),义务最多只能与该窗口同样老。 +每个被捕获的转换只有三种:**漂移**(completed、所选信号为 `true`、pin 修订)、**干预条件不成立**(completed、信号为 `false`)或**未评估**,后者带一个类型化原因(`pending`、`failed`、`abstained`、`stale`、`undecided`、`missing`、`unattributed`、`identity_conflict`、`other_revision`、`not_evaluated`)。形成是保守的:义务需要 `drift_threshold` 个连续漂移转换,中间不能有未评估转换。存续则不然:一旦形成,更新的未评估转换既不延长也不解除义务,其数量作为 `unevaluated_transitions` 记在 trigger 上。只有已确认的重规划或更新的 completed 未触发判定能结束它;Goal 负责人也可以把模式调回 `shadow` 或 `off`。绑定到非 pin 修订的回执是未评估的历史,永不计数。扫描范围是 Goal 保留的 run 历史(`latest_runs`),义务最多只能与该窗口同样老。 + +## Criterion 与观察读回 + +规范任务 criterion 通过观察器的显式 +[`acceptance_scope`](../../../packages/loopx-jev/DRIFT_SHADOW.zh-CN.md#绑定规范任务-criterion) +绑定:读取既有验收 owner,不信任 manifest 自述的 acceptance。手写 basis 仍标为 +`operator_study`。规范 criterion binding 变化或不可用时,该回执投影为过期并撤回信号; +保留存储历史与政策的手动 revision pin,旧格式回执沿用原行为。 + +在 **Goal 设置 → 能力中心 → 进展评估哨兵** 中,已启用的 Goal 分别展示目标关系与 +证据增量、criterion 来源、选中文件覆盖及缺失/过期/不可用状态。点 +**重新读取审查证据** 刷新,读取期间撤回旧判断。`off_goal` 与 `new_evidence` 可以 +同时成立且组合信号未触发,不能据此称为符合目标。回执存储读取失败保持不可用, +不沿用缓存成功。模型分数不构成任务验收,也不授予停止、回滚或完成权限;`off` 不读取。 ## 解除 diff --git a/packages/loopx-jev/DRIFT_SHADOW.md b/packages/loopx-jev/DRIFT_SHADOW.md index c100c0c859..0fdcd039e0 100644 --- a/packages/loopx-jev/DRIFT_SHADOW.md +++ b/packages/loopx-jev/DRIFT_SHADOW.md @@ -47,7 +47,7 @@ collector does not discover relevant files or scan the entire repository. | Material | How it enters the assessment | | --- | --- | -| Goal and acceptance criteria | Operator-provided basis JSON | +| Goal and acceptance criteria | Operator-provided basis; with explicit `acceptance_scope`, current canonical task criteria | | Files named by `--path` | Before/after contents and net changes; a not-yet-created file is allowed | | Optional `evidence` references in the basis | Explicitly named regular files, such as a test or probe report | | Other source, dependencies or conversation history | Not automatically read; its absence limits the judgment | @@ -64,6 +64,40 @@ the test that exercises it. A judgment from that packet cannot certify the full behavior. Include relevant tests, results and dependencies deliberately; if the necessary material does not fit, do not present the partial packet as complete. +## Bind canonical task criteria + +The basis may include this private, local scope in addition to its existing +`goal_id`, `objective` and nonempty `acceptance` fields: + +```json +"acceptance_scope": { + "registry_ref": ".loopx/registry.json", + "runtime_ref": ".loopx/runtime", + "agent_id": "reviewer", + "todo_id": "todo_review", + "criterion_ids": ["criterion_review"] +} +``` + +Use real owner paths and criterion IDs for that task. Both references must stay +inside the selected workspace; escapes and symlinks are rejected. The examples +are local operator configuration, not files to publish. The observer reads the +current acceptance owner and replaces the model's acceptance list with the +selected canonical descriptions. Private registry/runtime references are not +sent to the model or persisted in public receipt scope. Without this block the +basis is explicitly an operator study, not canonical acceptance. + +The binding pins Goal/Agent/Todo identity, contract revision/digest, task semantics +and selected criteria. Capture rejects another task or agent before model dispatch; +core receipt decoding applies the same identity rule. Select a new matching basis +when changing tasks. +A change invalidates in-flight assessment; a fresh observation needs a new basis +revision/baseline through the existing initialization path. Core readback also +withdraws a newly scoped receipt when its canonical binding is no longer current. +The Goal's `assist` revision pin stays manual. Declared file net changes do not +cover every intermediate decision or omitted dependency, and binding does not +certify model correctness or grant mutation authority. + ## Run it Use Python 3.11+ and the Node runtime required by the LoopX checkout. This diff --git a/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md b/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md index cef0cfeb0a..02a51391d1 100644 --- a/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md +++ b/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md @@ -27,6 +27,33 @@ 例如只选择函数所在文件,而漏掉被调用的 helper 和对应测试,评估就不能证明完整行为。应主动纳入相关测试、结果和依赖;必要材料装不下时,不能把部分材料包装成完整证据。 +## 绑定规范任务 criterion + +在既有 `goal_id`、`objective` 和非空 `acceptance` 字段之外,basis 可以包含这个 +私有本机范围: + +```json +"acceptance_scope": { + "registry_ref": ".loopx/registry.json", + "runtime_ref": ".loopx/runtime", + "agent_id": "reviewer", + "todo_id": "todo_review", + "criterion_ids": ["criterion_review"] +} +``` + +替换为该任务真实 owner 路径与 criterion ID。两个引用必须位于选中 workspace 内, +拒绝越界与符号链接;这些是本地操作者配置,不应发布。观察器读取当前验收 owner, +用选中的规范描述替换模型 acceptance 列表。私有 registry/runtime 引用不发送给模型, +也不进入公开回执范围。没有该块时,basis 明确属于 operator study,不是规范验收。 + +binding 固定 Goal/Agent/Todo 身份、contract 修订/摘要、任务语义及所选 criterion。 +采集在模型派发前拒绝错配任务或 Agent,核心回执解码使用同一规则;切换任务须选用 +匹配的新依据。变更使在途评估失效; +新的观察沿既有初始化入口重建 basis revision/baseline。核心读回也会在新格式回执的 +规范 binding 不再当前时撤回信号。Goal 的 `assist` revision pin 仍是手动的。 +声明文件净变化不覆盖每条中间判断或遗漏依赖;绑定不证明模型正确,也不授予修改权限。 + ## 操作方法 使用 Python 3.11+ 和 LoopX 检出要求的 Node 运行时。采集器目前面向 Linux/macOS 的 POSIX 文件处理;Windows 采集未验证,缺少所需文件原语时记录观察不可用。此可选发行包不进入 LoopX 默认 wheel;从源码根目录在新环境中安装,再执行不需要 key 的集成测试: From 6001cadde7f2eb36fb1b2beb227e5741ea03aa16 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 10 Oct 2026 10:58:58 +0800 Subject: [PATCH 3/6] tests: qualify criterion-bound review Signed-off-by: song --- .../smoke/progress-review-evidence-smoke.mjs | 69 ++++++++++++ tests/capabilities/test_progress_review.py | 21 ++++ .../test_external_progress_review.py | 2 +- .../progress_review_evidence.test.ts | 28 +++++ .../progress_review_evidence_fixture.py | 93 ++++++++++++++++ .../test_progress_review_criterion_binding.py | 104 ++++++++++++++++++ 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 apps/presentation/dashboard/smoke/progress-review-evidence-smoke.mjs create mode 100644 tests/control_plane_ts/progress_review_evidence.test.ts create mode 100644 tests/presentation/progress_review_evidence_fixture.py create mode 100644 tests/test_progress_review_criterion_binding.py diff --git a/apps/presentation/dashboard/smoke/progress-review-evidence-smoke.mjs b/apps/presentation/dashboard/smoke/progress-review-evidence-smoke.mjs new file mode 100644 index 0000000000..4b409bba31 --- /dev/null +++ b/apps/presentation/dashboard/smoke/progress-review-evidence-smoke.mjs @@ -0,0 +1,69 @@ +// Packaged reader + production Chat HTTP, delegation workers and independent checks. +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {createInterface} from "node:readline"; +import {mkdir} from "node:fs/promises"; +import {resolve} from "node:path"; +import {resolveTestPython} from "../../../../scripts/test-python.mjs"; +import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs"; +process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1"; +const {repoRoot, outputDir, port, startServer} = await import("../../../../examples/personal-workspace-browser/fixture.mjs"); +const {openWorkspacePage} = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +const fixture = spawn(resolveTestPython({repoRoot}), ["-u", "tests/presentation/progress_review_evidence_fixture.py"], {cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"]}); +const exited = once(fixture, "exit"); +const lines = createInterface({input: fixture.stdout}); +const iterator = lines[Symbol.asyncIterator](); +let stderr = ""; +fixture.stderr.on("data", chunk => {stderr += chunk;}); +async function next() {const line = await iterator.next(); assert.equal(line.done, false, stderr); return JSON.parse(line.value);} +async function command(value) {fixture.stdin.write(`${value}\n`); return next();} +let server, browser, workspace; +try { + const {origin, goal_id} = await next(); + server = await startServer(); + const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; + await waitForHttp(url); + browser = await launchBrowser(loadPlaywright().chromium); + workspace = await openWorkspacePage({newPage: options => browser.newPage({locale: "zh-CN", ...options})}, url); + const {page, api} = workspace; + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + await mkdir(outputDir, {recursive: true}); + await page.setViewportSize({width: 390, height: 844}); + await page.route("**/api/chat/goal-configuration?*", async route => { + const response = await page.request.get(`${origin}/api/chat/goal-configuration?goal_id=${encodeURIComponent(goal_id)}`); + return route.fulfill({response}); + }); + await page.getByRole("button", {name: "Goal 设置", exact: true}).click(); + await page.getByRole("button", {name: "能力中心", exact: true}).click(); + await page.getByRole("button", {name: /进展评估哨兵/}).click(); + const review = page.getByRole("region", {name: "审查证据读回"}); + await review.getByText("绑定任务的规范验收条款", {exact: true}).waitFor(); + await review.getByText("与目标的关系: off_goal", {exact: true}).waitFor(); + await review.getByText("新证据增量: new_evidence", {exact: true}).waitFor(); + await review.getByText("仅观察声明文件在两个检查点之间的净变化", {exact: true}).waitFor(); + const coverage = review.getByText("仅观察声明文件在两个检查点之间的净变化", {exact: true}); + await coverage.scrollIntoViewIfNeeded(); + await review.getByText("观察范围与条款版本", {exact: true}).click(); + await review.locator("code").scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "progress-review-mixed-dimensions-mobile.png")}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false); + await page.setViewportSize({width: 1280, height: 960}); + await review.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "progress-review-mixed-dimensions-desktop.png")}); + await command("review-task-edit"); + await review.getByRole("button", {name: "重新读取审查证据", exact: true}).click(); + await review.getByText("条款或任务关联已变化,旧判断不可作为当前依据。", {exact: true}).waitFor(); + await review.getByText("最新审查: stale", {exact: true}).waitFor(); + await command("review-missing"); + await review.getByRole("button", {name: "重新读取审查证据", exact: true}).click(); + await review.getByText("尚无审查回执。", {exact: true}).waitFor(); + assert.equal(await review.getByText("与目标的关系: off_goal", {exact: true}).count(), 0); + console.log("PASS: packaged scoped shadow readback, separate dimensions, changed-task withdrawal and missing evidence via production HTTP"); +} catch (error) { + if (workspace) console.error({body: (await workspace.page.locator("body").innerText()).slice(-3000), errors: workspace.errors}); + throw error; +} finally { + await workspace?.close(); await browser?.close(); server?.kill("SIGTERM"); + fixture.stdin.end(); const [code] = await exited; lines.close(); assert.equal(code, 0, stderr); +} diff --git a/tests/capabilities/test_progress_review.py b/tests/capabilities/test_progress_review.py index 9468e986cc..3b12a86f96 100644 --- a/tests/capabilities/test_progress_review.py +++ b/tests/capabilities/test_progress_review.py @@ -362,3 +362,24 @@ def test_cli_flags_reach_configure_goal(tmp_path: Path) -> None: "drift_threshold": 5, "contract_revision": None, } + + +def test_readback_separates_dimensions_and_keeps_missing_and_io_unknown(tmp_path, monkeypatch): + from loopx.capabilities.progress_review.context import external_progress_review_context + from loopx.capabilities.progress_review import receipt as receipt_module + goal = {"id": GOAL_ID, "control_plane": {"progress_review": {"mode": "shadow"}}} + assert external_progress_review_context({}, tmp_path) is None + assert external_progress_review_context(goal, tmp_path)["summary"]["read_state"] == "missing" + mixed = receipt(judgments={"choice": {"relation": "off_goal", "increment": "new_evidence"}, "noul": { + "behavior_change": 0.9, "serves_acceptance": 0.1, "evidence_increment": 0.9}}, + drift_signal={"noul": False, "choice": False}) + write_progress_review_receipt(tmp_path, GOAL_ID, mixed) + latest = external_progress_review_context(goal, tmp_path)["summary"]["latest"] + assert latest["judgments"]["choice"] == {"relation": "off_goal", "increment": "new_evidence"} + def unreadable(*args, **kwargs): + raise OSError("disposable storage unavailable") + monkeypatch.setattr(receipt_module, "load_progress_review_receipts", unreadable) + summary = external_progress_review_context(goal, tmp_path)["summary"] + assert summary["read_state"] == "unavailable" + assert summary["read_error"] == "receipt_store_unavailable" + assert summary["latest"] is None diff --git a/tests/control_plane/test_external_progress_review.py b/tests/control_plane/test_external_progress_review.py index 2c1bd14fed..bfffd12c87 100644 --- a/tests/control_plane/test_external_progress_review.py +++ b/tests/control_plane/test_external_progress_review.py @@ -369,7 +369,7 @@ def test_newer_drift_after_formation_extends_and_rebinds_the_baseline() -> None: assert result["oldest_counted_generated_at"] == "2026-09-21T00:00:01Z" -def test_a_newer_completed_on_goal_verdict_ends_the_open_condition() -> None: +def test_a_newer_completed_nontriggering_signal_ends_the_open_condition() -> None: """A positive verdict on a newer transition is evidence; a missing one is not.""" runs = [run(3, turn="t3"), run(2, turn="t2"), run(1, turn="t1")] diff --git a/tests/control_plane_ts/progress_review_evidence.test.ts b/tests/control_plane_ts/progress_review_evidence.test.ts new file mode 100644 index 0000000000..02fb5e1b98 --- /dev/null +++ b/tests/control_plane_ts/progress_review_evidence.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {progressReviewCriterionBasis, progressReviewEvidenceScope} from "../../loopx/control_plane/work_items/progress_review_evidence.ts"; +const identity = {goal_id: "goal", agent_id: "agent"}; +const run = {agent_id: "agent", todo_id: "task"}; +const requirements = {todo_id: "task", contract_revision: 1, contract_digest: "a".repeat(64), + todo_semantic_digest: "b".repeat(64), criteria: [{id: "check", description: "Prove the declared postcondition"}]}; +test("canonical criteria come from the task's current scope, not operator assertions", () => { + const result = progressReviewCriterionBasis({...identity, requirements, criterion_ids: ["check"], acceptance: ["Claim success"]}); + assert.deepEqual(result.acceptance, ["Prove the declared postcondition"]); + assert.equal((result.binding as any).origin, "goal_acceptance"); + assert.throws(() => progressReviewCriterionBasis({...identity, requirements, criterion_ids: ["another"]})); + assert.throws(() => progressReviewCriterionBasis({...identity, requirements, criterion_ids: ["check", "check"]})); + const manual = progressReviewCriterionBasis({requirements: null, acceptance: ["Study this behavior"]}); + assert.equal((manual.binding as any).origin, "operator_study"); + const scope = {criterion_binding: result.binding, coverage: "declared_file_net_change", files: ["code.ts"]}; + assert.deepEqual(progressReviewEvidenceScope({...identity, run, scope}).scope, scope); + assert.throws(() => progressReviewEvidenceScope({...identity, run, scope: {...scope, files: ["../outside"]}})); + assert.throws(() => progressReviewEvidenceScope({...identity, run, scope: {...scope, coverage: "all_steps"}})); +}); + +test("canonical criteria cannot be used for a different goal, agent or task", () => { + const basis = progressReviewCriterionBasis({...identity, requirements, criterion_ids: ["check"]}); + const scope = {criterion_binding: basis.binding, coverage: "declared_file_net_change", files: []}; + for (const changed of [{goal_id: "other", run}, {goal_id: "goal", run: {...run, todo_id: "other"}}, + {goal_id: "goal", run: {...run, agent_id: "other"}}, {goal_id: "goal", run: {}}]) + assert.throws(() => progressReviewEvidenceScope({...changed, scope})); +}); diff --git a/tests/presentation/progress_review_evidence_fixture.py b/tests/presentation/progress_review_evidence_fixture.py new file mode 100644 index 0000000000..779b075d9c --- /dev/null +++ b/tests/presentation/progress_review_evidence_fixture.py @@ -0,0 +1,93 @@ +"""Disposable production delegation + Chat HTTP for packaged recovery validation.""" +from pathlib import Path +import json +import sys +import tempfile +import threading +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import pytest +from test_local_delegation import service +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.chat_store import ChatSessionStore + + +def main(): + cleanups = [] + patch = pytest.MonkeyPatch() + with tempfile.TemporaryDirectory(prefix="loopx-result-use-") as directory: + try: + root, runner = service.__wrapped__(Path(directory), + SimpleNamespace(param="file", addfinalizer=cleanups.append), patch) + config = root / "project/.loopx/config/delegations.json" + config.parent.mkdir(parents=True, exist_ok=True) + catalog = json.loads(runner.config.read_text()) + config.write_text(json.dumps(catalog)) + runner.config = config + registry = json.loads(runner.registry.read_text()) + goal = next(row for row in registry["goals"] if row["id"] == runner.goal_id) + goal.setdefault("control_plane", {})["progress_review"] = {"mode": "shadow"} + runner.registry.write_text(json.dumps(registry)) + from tests.capabilities.test_progress_review import receipt + from loopx.capabilities.progress_review.receipt import write_progress_review_receipt + from loopx.control_plane.effect_runtime import effect_runtime_result + from loopx.control_plane.goals.acceptance import inspect_goal_acceptance + requirements = inspect_goal_acceptance(registry_path=runner.registry, runtime_root=str(runner.root), + goal_id=runner.goal_id, agent_id="analyst", todo_id="todo_analyst-initial")["completion_requirements"] + binding = effect_runtime_result("progress_review.criterion_basis", { + "requirements": requirements, "acceptance": [], "criterion_ids": ["analyst-initial"], + "goal_id": runner.goal_id, "agent_id": "analyst", + })["binding"] + review = receipt(goal_id=runner.goal_id, + judgments={"choice": {"relation": "off_goal", "increment": "new_evidence"}, "noul": { + "behavior_change": 0.9, "serves_acceptance": 0.1, "evidence_increment": 0.9}}, + drift_signal={"noul": False, "choice": False}, + evidence_scope={"criterion_binding": binding, "coverage": "declared_file_net_change", "files": ["code.ts"]}) + review["run"].update(agent_id="analyst", todo_id="todo_analyst-initial") + review_path = write_progress_review_receipt(runner.root, runner.goal_id, review) + store = ChatSessionStore(runner.root) + controller = ChatRuntimeController(store=store, codex_bin="missing-codex", registry_path=runner.registry) + store.create_session(goal_id=runner.goal_id, agent_id="codex", channel_id=f"goal.{runner.goal_id}", + upstream_thread_id="fixture", session_id="recovery", adapter_kind="codex_app_server", upstream_mode="chat") + store.update_session("recovery", native_goal={"status": "active"}, loopx_mode={"settings": { + "agent_id": "lead", "execution_config": ".loopx/config/delegations.json"}}) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.registry_path = runner.registry + server.runtime_root = runner.root + server.chat_store = store + server.runtime_controller = controller + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + print(json.dumps({"origin": f"http://127.0.0.1:{server.server_port}", "goal_id": runner.goal_id}), flush=True) + try: + for command in sys.stdin: + action = command.strip() + if action == "review-missing": + review_path.unlink() + elif action == "review-task-edit": + changed = runner._cli(runner.binding("analysis"), "todo", "update", "--goal-id", runner.goal_id, + "--agent-id", "analyst", "--todo-id", "todo_analyst-initial", "--text", "Changed declared scope", + "--update-operation-id", "review-task-scope-edit") + assert changed["ok"] is True, changed + elif action == "review-restore": + write_progress_review_receipt(runner.root, runner.goal_id, review) + else: + raise ValueError("unknown fixture command") + print(json.dumps({"ok": True}), flush=True) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + controller.close() + finally: + for cleanup in reversed(cleanups): + cleanup() + patch.undo() + + +if __name__ == "__main__": + main() diff --git a/tests/test_progress_review_criterion_binding.py b/tests/test_progress_review_criterion_binding.py new file mode 100644 index 0000000000..e8df697891 --- /dev/null +++ b/tests/test_progress_review_criterion_binding.py @@ -0,0 +1,104 @@ +"""Version-bound requester decisions through real Turn acceptance and durable IO.""" +import json +import subprocess +import sys + +import pytest + +from test_local_delegation import service as delegation_service + +service = delegation_service + + +def test_shadow_basis_uses_current_canonical_criteria_and_withdraws_after_task_edit(service): + from pathlib import Path + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packages/loopx-jev/src")) + from loopx_jev.runner import read_basis + root, runner = service + manifest = root / "basis.json" + manifest.write_text(json.dumps({"goal_id": runner.goal_id, "objective": "Review declared task change", + "acceptance": ["Operator claim must not replace the canonical criterion"], "evidence": [], + "acceptance_scope": {"registry_ref": "registry.json", "runtime_ref": "runtime", "agent_id": "analyst", + "todo_id": "todo_analyst-initial", "criterion_ids": ["analyst-initial"]}})) + basis, current = read_basis(manifest, root) + assert basis["criterion_binding"]["origin"] == "goal_acceptance" + assert basis["criterion_binding"]["criterion_ids"] == ["analyst-initial"] + assert basis["acceptance"] == ["Independent checks for analyst initial"] + assert current() + from tests.capabilities.test_progress_review import receipt + from loopx.capabilities.progress_review.receipt import write_progress_review_receipt + from loopx.capabilities.progress_review.context import external_progress_review_context + goal = {"id": runner.goal_id, "control_plane": {"progress_review": {"mode": "shadow"}}} + canonical_receipt = receipt(goal_id=runner.goal_id, + evidence_scope={"criterion_binding": basis["criterion_binding"], "coverage": "declared_file_net_change", "files": ["output.json"]}) + canonical_receipt["run"].update(agent_id="analyst", todo_id="todo_analyst-initial") + for mismatch in ({"todo_id": "todo_reviewer-corrected"}, {"agent_id": "reviewer"}): + with pytest.raises(ValueError, match="evidence scope"): + write_progress_review_receipt(runner.root, runner.goal_id, + {**canonical_receipt, "run": {**canonical_receipt["run"], **mismatch}}) + persisted = write_progress_review_receipt(runner.root, runner.goal_id, canonical_receipt) + persisted.write_text(json.dumps({**canonical_receipt, "run": {**canonical_receipt["run"], "todo_id": "todo_reviewer-corrected"}})) + rejected_context = external_progress_review_context(goal, runner.root) + assert rejected_context["receipts"] == [] + write_progress_review_receipt(runner.root, runner.goal_id, canonical_receipt) + assert external_progress_review_context(goal, runner.root)["summary"]["latest"]["criterion_current"] is True + # Exercise the real consumer too: canonical revisions include the owner + # binding, so comparing only the unchanged manifest digest would reject all jobs. + from loopx_jev import drift + from loopx_jev.store import atomic_json + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packages/loopx-jev/tests")) + from drift_fixtures import response + def git(*args): + subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True) + git("init", "-q") + git("config", "user.email", "fixture@example.invalid") + git("config", "user.name", "Fixture") + probe = root / "probe.txt" + probe.write_text("before\n") + git("add", "probe.txt") + git("commit", "-qm", "fixture baseline") + config = root / "shadow-config.json" + atomic_json(config, {"schema_version": "loopx_jev_drift_config_v0", "mode": "shadow", + "scenarios": ["progress_review"], "model": "fixture-v1", "allow_egress": True}) + observer = root / "observer" + drift.initialize(observer, root, manifest, config, ["probe.txt"], runtime_root=runner.root) + def queue(sequence): + probe.write_text(f"changed {sequence}\n") + record = root / f"run-{sequence}.json" + atomic_json(record, {"goal_id": runner.goal_id, "generated_at": f"2026-01-01T00:00:0{sequence}Z", + "agent_id": "analyst", "todo_id": "todo_analyst-initial", "turn_instance_id": f"shadow-{sequence}"}) + prepared = drift.prepare(observer, config) + original = json.loads(record.read_text()) + for mismatch in ({"todo_id": "todo_reviewer-corrected"}, {"agent_id": "reviewer"}): + atomic_json(record, {**original, **mismatch}) + with pytest.raises(ValueError, match="run_acceptance_scope_mismatch"): + drift.enqueue(observer, prepared, record) + atomic_json(record, original) + return drift.enqueue(observer, prepared, record)["event_id"] + calls = [] + def send(request, *_): + calls.append(request) + assert "acceptance_scope" not in request["state"]["goal_basis"] + assert request["state"]["goal_basis"]["acceptance"] == ["Independent checks for analyst initial"] + return {"response": response(request, ["off_goal", "new_evidence"])} + event = queue(1) + drift.drain(observer, config, transport=send, credential=lambda: "fixture") + evaluated = next(row for row in drift.status(observer)["events"] if row["event_id"] == event) + assert evaluated["status"] == "completed", evaluated + assert len(calls) == 1 + queue(2) + def edit_during_request(request, *args): + result = runner._cli(runner.binding("analysis"), "todo", "update", "--goal-id", runner.goal_id, + "--agent-id", "analyst", "--todo-id", "todo_analyst-initial", "--text", "Changed declared task scope", + "--update-operation-id", "shadow-scope-edit") + assert result["ok"] is True, result + return send(request, *args) + drift.drain(observer, config, transport=edit_during_request, credential=lambda: "fixture") + assert len(calls) == 2 + assert all(row["status"] == "stale" for row in drift.status(observer)["events"] if row["event_id"] != event) + assert not current(), "A stale owner binding cannot be promoted by an unchanged study manifest" + latest = external_progress_review_context(goal, runner.root)["summary"]["latest"] + assert latest["status"] == "stale" and latest["criterion_current"] is False + assert latest["drift_signal"] == {"noul": None, "choice": None} + + From ddd1ed764c0d8b95e502ad3f787a345cfffdbc8a Mon Sep 17 00:00:00 2001 From: song Date: Sat, 10 Oct 2026 13:00:01 +0800 Subject: [PATCH 4/6] fix(progress-review): preserve exact Goal identity and legacy provider compatibility Signed-off-by: song --- loopx/capabilities/progress_review/context.py | 4 ++++ packages/loopx-jev/src/loopx_jev/runner.py | 22 +++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/loopx/capabilities/progress_review/context.py b/loopx/capabilities/progress_review/context.py index 8f8eed1530..66ba9f909c 100644 --- a/loopx/capabilities/progress_review/context.py +++ b/loopx/capabilities/progress_review/context.py @@ -34,6 +34,7 @@ def external_progress_review_context( if policy["mode"] == "off" or runtime_root is None or not goal_id: return None from ...control_plane.effect_runtime import effect_runtime_result, EffectRuntimeRemoteError + from ...control_plane.goals.goal_ref_validation import exact_goal_ref read_error = None try: loaded, rejected = load_progress_review_receipts(Path(runtime_root), goal_id) @@ -50,8 +51,11 @@ def external_progress_review_context( todo_id = binding["todo_id"] if todo_id not in current_by_todo: try: + goal_instance_id = goal.get("goal_instance_id") observation = effect_runtime_result("goal.acceptance.inspect", { "goal_id": goal_id, "runtime_root": str(runtime_root), "todo_id": todo_id, + **({"goal_ref": exact_goal_ref(goal_id, goal_instance_id)} + if goal_instance_id is not None else {}), }) current_by_todo[todo_id] = observation.get("completion_requirements") except (OSError, ValueError, EffectRuntimeRemoteError): diff --git a/packages/loopx-jev/src/loopx_jev/runner.py b/packages/loopx-jev/src/loopx_jev/runner.py index 9810552c92..c8458c6309 100644 --- a/packages/loopx-jev/src/loopx_jev/runner.py +++ b/packages/loopx-jev/src/loopx_jev/runner.py @@ -86,14 +86,14 @@ def read_basis( } ) checks.append((target, digest)) - from loopx.control_plane.effect_runtime import effect_runtime_result - from loopx.control_plane.goals.acceptance import inspect_goal_acceptance + from loopx.control_plane.effect_runtime import effect_runtime_result, EffectRuntimeRejected scope = manifest.get("acceptance_scope") def criterion_basis(): requirements = None requested = None if scope is not None: + from loopx.control_plane.goals.acceptance import inspect_goal_acceptance if not isinstance(scope, dict) or set(scope) != { "registry_ref", "runtime_ref", "agent_id", "todo_id", "criterion_ids" }: @@ -114,17 +114,25 @@ def local_reference(key): if requirements is None: raise ValueError("current task has no canonical acceptance criteria") requested = scope["criterion_ids"] - return effect_runtime_result("progress_review.criterion_basis", { - "acceptance": manifest["acceptance"], "requirements": requirements, "criterion_ids": requested, - "goal_id": manifest.get("goal_id"), "agent_id": scope.get("agent_id") if scope else None, - }) + try: + return effect_runtime_result("progress_review.criterion_basis", { + "acceptance": manifest["acceptance"], "requirements": requirements, "criterion_ids": requested, + "goal_id": manifest.get("goal_id"), "agent_id": scope.get("agent_id") if scope else None, + }) + except EffectRuntimeRejected as exc: + # Older independently installed cores still support manual studies. + # Keep their legacy shape; never fabricate a canonical binding or + # bypass a current core's validation/IO failure. + if scope is None and exc.diagnostic_code == "unsupported_method": + return {"acceptance": manifest["acceptance"]} + raise selected = criterion_basis() basis = { **manifest, "evidence": observations, "basis_origin": "explicit_operator_study_basis_not_completion_authority", "acceptance": selected["acceptance"], - "criterion_binding": selected["binding"], + **({"criterion_binding": selected["binding"]} if "binding" in selected else {}), } def current() -> bool: From 508d6a582a9a849af8ae4881f8a10d2fd843ce09 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 10 Oct 2026 13:00:01 +0800 Subject: [PATCH 5/6] docs(progress-review): clarify instance and independent install boundaries Signed-off-by: song --- ...osable-state-machines-recovery-verification-v0.md | 2 +- ...-state-machines-recovery-verification-v0.zh-CN.md | 2 +- packages/loopx-jev/DRIFT_SHADOW.md | 12 ++++++++++++ packages/loopx-jev/DRIFT_SHADOW.zh-CN.md | 8 ++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md index 1da8ad873e..7d92d0726c 100644 --- a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md +++ b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md @@ -310,7 +310,7 @@ The table tracks independent composition boundaries within M2/M3 and roadmap R2/ | --- | --- | --- | | Unavailable declared ancestry prevents new dependent use | `Delegations._read_current/start`, `delegation_results.require_dependencies/adoption_evidence`, `delegation.ts` | Alignment §3.8: source → A → B with A input invalidated must refuse current dependent use even if A output is unchanged. Qualify real read/start/adopt/settlement and packaged evidence readback; keep historical completion. | | Independent check failure reaches an actionable original-task recovery journey | `executor._task_validation_stage`, `ValidatedTurnReceipt`, canonical `turn_loop_controller_contract_v0.json`, `turn_journal.ts` | Preserve qualified failure scope and repair/replan detail; complete bounded repair or verifier-only retry, current validation and original-effect settlement. Host assertions cannot become trusted validation. | -| Shadow review explains criteria and coverage | progress-review receipt/context, `progress_review_evidence.ts`, canonical acceptance inspect | Select current canonical task criteria; changed task bindings withdraw old judgments. Manual studies and legacy receipts retain distinct strength. Show separate dimensions, declared-file net change, missing evidence and unreadable storage. Default off and existing assist trigger rules retain their semantics. | +| Shadow review explains criteria and coverage | progress-review receipt/context, `progress_review_evidence.ts`, canonical acceptance inspect | Select current canonical task criteria; exact GoalRef preserves instance identity, while changed tasks or recreated instances withdraw old judgments. Independent older cores retain only the legacy manual-study shape; canonical scope never downgrades. Show separate dimensions, declared-file net change, missing evidence and unreadable storage. Default off and existing assist trigger rules retain their semantics. | For the second outcome, `_task_validation_stage` already saves the independent result and blocks settlement. `ValidatedTurnReceipt` omits that validation's diff --git a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md index 972be4f5b7..e24de651af 100644 --- a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md +++ b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md @@ -255,7 +255,7 @@ PostgreSQL authority、successor 调度和 App/Lark 送达尚未覆盖。因此 | --- | --- | --- | | 声明来源链不可用时,阻止新的依赖使用 | `Delegations._read_current/start`、`delegation_results.require_dependencies/adoption_evidence`、`delegation.ts` | 遵循对齐 §3.8:source → A → B 中 A input 失效,即使 A output 不变也须拒绝当前依赖使用;验证真实 read/start/adopt/settlement 和打包证据读回,保留历史完成。 | | 独立检查失败进入原任务可操作的恢复旅程 | `executor._task_validation_stage`、`ValidatedTurnReceipt`、canonical `turn_loop_controller_contract_v0.json`、`turn_journal.ts` | 传递已资格化失败范围与 repair/replan 细节;完成有界修复或只运行 verifier 的重试、当前验证与原效果结算。Host 声明不能冒充可信验证。 | -| shadow 说明条款及观察范围 | progress-review receipt/context、`progress_review_evidence.ts`、canonical acceptance inspect | 可选择任务当前规范 criterion;任务关联变化后撤回旧判断。手工研究和旧回执保持不同证据强度;显示独立维度、净文件变化覆盖、缺失及存储未知。默认 off 和 assist 既有触发规则保持不变。 | +| shadow 说明条款及观察范围 | progress-review receipt/context、`progress_review_evidence.ts`、canonical acceptance inspect | 可选择任务当前规范 criterion;精确 GoalRef 保持实例身份,任务变化或实例重建撤回旧判断。独立旧核心只保留手工研究的旧格式;规范 scope 不降级。显示独立维度、净文件变化覆盖、缺失及存储未知。默认 off 和 assist 既有触发规则保持不变。 | 第二项中,`_task_validation_stage` 已保存独立结果并阻断结算;`ValidatedTurnReceipt` 未携带该验证的 `recovery_kind`,canonical controller 有意将 legacy diff --git a/packages/loopx-jev/DRIFT_SHADOW.md b/packages/loopx-jev/DRIFT_SHADOW.md index 0fdcd039e0..d754179081 100644 --- a/packages/loopx-jev/DRIFT_SHADOW.md +++ b/packages/loopx-jev/DRIFT_SHADOW.md @@ -87,6 +87,15 @@ selected canonical descriptions. Private registry/runtime references are not sent to the model or persisted in public receipt scope. Without this block the basis is explicitly an operator study, not canonical acceptance. +Independent installations negotiate this method boundary. A core without +`progress_review.criterion_basis` still runs manual studies using the legacy +basis/receipt shape, without criterion scope metadata. This fallback applies +only to the typed `unsupported_method` rejection and only without +`acceptance_scope`; other failures remain errors. Canonical scope requires a +core implementing the criterion and evidence-scope methods. If either is +unavailable, upgrade the core before using that scope; it never becomes a +manual study automatically. + The binding pins Goal/Agent/Todo identity, contract revision/digest, task semantics and selected criteria. Capture rejects another task or agent before model dispatch; core receipt decoding applies the same identity rule. Select a new matching basis @@ -94,6 +103,9 @@ when changing tasks. A change invalidates in-flight assessment; a fresh observation needs a new basis revision/baseline through the existing initialization path. Core readback also withdraws a newly scoped receipt when its canonical binding is no longer current. +For an instantiated Goal, readback uses its exact GoalRef. Recreating the Goal +retires the old acceptance, so an unchanged task ID cannot keep its old +observation current. The retained historical receipt is not rewritten. The Goal's `assist` revision pin stays manual. Declared file net changes do not cover every intermediate decision or omitted dependency, and binding does not certify model correctness or grant mutation authority. diff --git a/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md b/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md index 02a51391d1..b261e91683 100644 --- a/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md +++ b/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md @@ -47,11 +47,19 @@ 用选中的规范描述替换模型 acceptance 列表。私有 registry/runtime 引用不发送给模型, 也不进入公开回执范围。没有该块时,basis 明确属于 operator study,不是规范验收。 +独立安装按方法能力区分兼容边界。核心缺少 `progress_review.criterion_basis` 时, +手工研究仍使用原 basis/回执格式,不生成 criterion scope 元数据。只在没有 +`acceptance_scope` 且收到类型化 `unsupported_method` 拒绝时保留这条路径;其他失败 +继续报错。规范 scope 要求核心实现 criterion 和 evidence-scope 方法,缺少任一方法 +须先升级核心;不会自动改成手工研究。 + binding 固定 Goal/Agent/Todo 身份、contract 修订/摘要、任务语义及所选 criterion。 采集在模型派发前拒绝错配任务或 Agent,核心回执解码使用同一规则;切换任务须选用 匹配的新依据。变更使在途评估失效; 新的观察沿既有初始化入口重建 basis revision/baseline。核心读回也会在新格式回执的 规范 binding 不再当前时撤回信号。Goal 的 `assist` revision pin 仍是手动的。 +已实例化 Goal 的读回使用精确 GoalRef;重建会退休旧验收,同一任务 ID 不能使旧观察 +继续有效。保留的历史回执不被改写。 声明文件净变化不覆盖每条中间判断或遗漏依赖;绑定不证明模型正确,也不授予修改权限。 ## 操作方法 From 8e47dc13ab321fa4eb88bd3903d9945cbdc1d7b1 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 10 Oct 2026 13:00:01 +0800 Subject: [PATCH 6/6] test(progress-review): qualify exact lifecycle and typed compatibility failures Signed-off-by: song --- packages/loopx-jev/tests/test_protocol.py | 31 ++++++ .../progress_review_evidence_fixture.py | 6 +- .../test_progress_review_criterion_binding.py | 94 +++++++++++++++++-- 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/packages/loopx-jev/tests/test_protocol.py b/packages/loopx-jev/tests/test_protocol.py index d709d9d639..a5176f315b 100644 --- a/packages/loopx-jev/tests/test_protocol.py +++ b/packages/loopx-jev/tests/test_protocol.py @@ -13,6 +13,37 @@ from loopx_jev.transport import send, TransportFailure +@pytest.mark.parametrize("code", ["unsupported_method", "invalid_request", "io_permission_denied"]) +def test_manual_basis_preserves_only_the_typed_legacy_method_boundary(tmp_path, monkeypatch, code): + from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, EffectRuntimeRejected, EffectRuntimePermanentIOError + from loopx_jev.runner import read_basis + + path = tmp_path / "basis.json" + manifest = {"goal_id": "study", "objective": "Study a declared file", + "acceptance": ["Observe its net change"], "evidence": [{"ref": "code.py"}]} + path.write_text(json.dumps(manifest)) + evidence = tmp_path / "code.py" + evidence.write_text("before") + calls = [] + def unsupported(method, params): + calls.append(method) + if code == "io_permission_denied": + raise EffectRuntimePermanentIOError("Fixture receipt store denied access", diagnostic_code=code) + raise EffectRuntimeRejected("Fixture core rejected the method", diagnostic_code=code) + monkeypatch.setattr("loopx.control_plane.effect_runtime.effect_runtime_result", unsupported) + if code != "unsupported_method": + with pytest.raises(EffectRuntimeRemoteError): + read_basis(path, tmp_path) + else: + basis, current = read_basis(path, tmp_path) + assert basis["acceptance"] == manifest["acceptance"] + assert "criterion_binding" not in basis + assert current() + evidence.write_text("after") + assert not current() + assert calls == ["progress_review.criterion_basis"] + + @pytest.mark.parametrize("raw", ['{"x":1,"x":2}', '{"x":NaN}', '{"x":Infinity}']) def test_ambiguous_json_rejected(raw): with pytest.raises(ValueError): diff --git a/tests/presentation/progress_review_evidence_fixture.py b/tests/presentation/progress_review_evidence_fixture.py index 779b075d9c..672dcd27f5 100644 --- a/tests/presentation/progress_review_evidence_fixture.py +++ b/tests/presentation/progress_review_evidence_fixture.py @@ -29,12 +29,16 @@ def main(): runner.config = config registry = json.loads(runner.registry.read_text()) goal = next(row for row in registry["goals"] if row["id"] == runner.goal_id) + goal["goal_instance_id"] = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" goal.setdefault("control_plane", {})["progress_review"] = {"mode": "shadow"} runner.registry.write_text(json.dumps(registry)) from tests.capabilities.test_progress_review import receipt from loopx.capabilities.progress_review.receipt import write_progress_review_receipt from loopx.control_plane.effect_runtime import effect_runtime_result - from loopx.control_plane.goals.acceptance import inspect_goal_acceptance + from loopx.control_plane.goals.acceptance import inspect_goal_acceptance, transition_goal_acceptance_lifecycle + transition_goal_acceptance_lifecycle(runtime_root=runner.root, goal_id=runner.goal_id, + operation_id="bind-presented-review-instance", transition={"kind": "bind_existing", "goal_ref": { + "goal_id": runner.goal_id, "goal_instance_id": goal["goal_instance_id"]}}) requirements = inspect_goal_acceptance(registry_path=runner.registry, runtime_root=str(runner.root), goal_id=runner.goal_id, agent_id="analyst", todo_id="todo_analyst-initial")["completion_requirements"] binding = effect_runtime_result("progress_review.criterion_basis", { diff --git a/tests/test_progress_review_criterion_binding.py b/tests/test_progress_review_criterion_binding.py index e8df697891..4cecff7c2a 100644 --- a/tests/test_progress_review_criterion_binding.py +++ b/tests/test_progress_review_criterion_binding.py @@ -1,7 +1,7 @@ """Version-bound requester decisions through real Turn acceptance and durable IO.""" import json +from pathlib import Path import subprocess -import sys import pytest @@ -10,11 +10,30 @@ service = delegation_service -def test_shadow_basis_uses_current_canonical_criteria_and_withdraws_after_task_edit(service): - from pathlib import Path - sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packages/loopx-jev/src")) +@pytest.fixture(autouse=True) +def provider_imports(monkeypatch): + package = Path(__file__).resolve().parents[1] / "packages/loopx-jev" + monkeypatch.syspath_prepend(str(package / "src")) + monkeypatch.syspath_prepend(str(package / "tests")) + + +def bind_exact_goal(runner): + from loopx.control_plane.goals.acceptance import transition_goal_acceptance_lifecycle + registry = json.loads(runner.registry.read_text()) + goal = next(row for row in registry["goals"] if row["id"] == runner.goal_id) + goal["goal_instance_id"] = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + runner.registry.write_text(json.dumps(registry)) + transition_goal_acceptance_lifecycle(runtime_root=runner.root, goal_id=runner.goal_id, + operation_id="bind-review-instance", transition={"kind": "bind_existing", + "goal_ref": {"goal_id": runner.goal_id, "goal_instance_id": goal["goal_instance_id"]}}) + return goal + + +@pytest.mark.parametrize("exact_instance", [False, True]) +def test_shadow_basis_uses_current_canonical_criteria_and_withdraws_after_task_edit(service, exact_instance): from loopx_jev.runner import read_basis root, runner = service + registered_goal = bind_exact_goal(runner) if exact_instance else {"id": runner.goal_id} manifest = root / "basis.json" manifest.write_text(json.dumps({"goal_id": runner.goal_id, "objective": "Review declared task change", "acceptance": ["Operator claim must not replace the canonical criterion"], "evidence": [], @@ -28,7 +47,7 @@ def test_shadow_basis_uses_current_canonical_criteria_and_withdraws_after_task_e from tests.capabilities.test_progress_review import receipt from loopx.capabilities.progress_review.receipt import write_progress_review_receipt from loopx.capabilities.progress_review.context import external_progress_review_context - goal = {"id": runner.goal_id, "control_plane": {"progress_review": {"mode": "shadow"}}} + goal = {**registered_goal, "control_plane": {"progress_review": {"mode": "shadow"}}} canonical_receipt = receipt(goal_id=runner.goal_id, evidence_scope={"criterion_binding": basis["criterion_binding"], "coverage": "declared_file_net_change", "files": ["output.json"]}) canonical_receipt["run"].update(agent_id="analyst", todo_id="todo_analyst-initial") @@ -42,11 +61,13 @@ def test_shadow_basis_uses_current_canonical_criteria_and_withdraws_after_task_e assert rejected_context["receipts"] == [] write_progress_review_receipt(runner.root, runner.goal_id, canonical_receipt) assert external_progress_review_context(goal, runner.root)["summary"]["latest"]["criterion_current"] is True + if exact_instance: + wrong_instance = {**goal, "goal_instance_id": "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + assert external_progress_review_context(wrong_instance, runner.root)["summary"]["latest"]["criterion_current"] is False # Exercise the real consumer too: canonical revisions include the owner # binding, so comparing only the unchanged manifest digest would reject all jobs. from loopx_jev import drift from loopx_jev.store import atomic_json - sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packages/loopx-jev/tests")) from drift_fixtures import response def git(*args): subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True) @@ -102,3 +123,64 @@ def edit_during_request(request, *args): assert latest["drift_signal"] == {"noul": None, "choice": None} +def test_scoped_basis_never_downgrades_an_unsupported_core_to_manual(service, monkeypatch): + from loopx.control_plane import effect_runtime + from loopx_jev.runner import read_basis + root, runner = service + manifest = root / "scoped-basis.json" + manifest.write_text(json.dumps({"goal_id": runner.goal_id, "objective": "Review the declared task", + "acceptance": ["Manual wording cannot replace canonical criteria"], + "acceptance_scope": {"registry_ref": "registry.json", "runtime_ref": "runtime", "agent_id": "analyst", + "todo_id": "todo_analyst-initial", "criterion_ids": ["analyst-initial"]}})) + original = effect_runtime.effect_runtime_result + def old_core(method, params, **kwargs): + if method == "progress_review.criterion_basis": + raise effect_runtime.EffectRuntimeRejected("Method unavailable", diagnostic_code="unsupported_method") + return original(method, params, **kwargs) + monkeypatch.setattr(effect_runtime, "effect_runtime_result", old_core) + with pytest.raises(effect_runtime.EffectRuntimeRejected): + read_basis(manifest, root) + + +def test_recreated_goal_with_same_task_never_reuses_the_retired_observation(service): + from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + from loopx.control_plane.goals.source_session_recreation import RecreateGoalRequest, recreate_goal_instance + from loopx_jev.runner import read_basis + from tests.capabilities.test_progress_review import receipt + from loopx.capabilities.progress_review.receipt import write_progress_review_receipt + from loopx.capabilities.progress_review.context import external_progress_review_context + + root, runner = service + goal = bind_exact_goal(runner) + goal["control_plane"] = {"progress_review": {"mode": "shadow"}} + source_registry = root / "source-registry.json" + payload = {"schema_version": "0.2", "registry_role": "project-local", "profile_id": "source_session_v1", + "common_runtime_root": str(runner.root), "projects": [], "goals": [goal], + "session_bindings": [], "session_receipts": [], "lifetime_receipts": [], "retired_goal_instances": []} + with source_session_registry_transaction(source_registry, operation="review-source-fixture", create=lambda: payload) as tx: + tx.commit(tx.payload_copy()) + manifest = root / "source-basis.json" + manifest.write_text(json.dumps({"goal_id": runner.goal_id, "objective": "Review exact instance work", + "acceptance": ["Canonical basis required"], "acceptance_scope": { + "registry_ref": "registry.json", "runtime_ref": "runtime", "agent_id": "analyst", + "todo_id": "todo_analyst-initial", "criterion_ids": ["analyst-initial"]}})) + basis, current = read_basis(manifest, root) + observed = receipt(goal_id=runner.goal_id, evidence_scope={"criterion_binding": basis["criterion_binding"], + "coverage": "declared_file_net_change", "files": ["output.json"]}) + observed["run"].update(agent_id="analyst", todo_id="todo_analyst-initial") + path = write_progress_review_receipt(runner.root, runner.goal_id, observed) + historical = path.read_bytes() + assert current() + assert external_progress_review_context(goal, runner.root)["summary"]["latest"]["criterion_current"] is True + recreated = recreate_goal_instance(RecreateGoalRequest(registry_path=source_registry, + goal_id=runner.goal_id, goal_instance_id=goal["goal_instance_id"], operation_id="recreate-review-instance")) + assert recreated["ok"] is True, recreated + assert recreated["goal_ref"]["goal_instance_id"] != goal["goal_instance_id"] + active = {**goal, "goal_instance_id": recreated["goal_ref"]["goal_instance_id"]} + assert not current() + for instance in (goal, active): + latest = external_progress_review_context(instance, runner.root)["summary"]["latest"] + assert latest["criterion_current"] is False and latest["status"] == "stale" + assert latest["drift_signal"] == {"noul": None, "choice": None} + assert path.read_bytes() == historical +