diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1cd034d2..c075fabe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,11 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + publish_candidate: + description: Publish the verified installers as an unsigned Owner Dogfood prerelease + type: boolean + default: false permissions: contents: read @@ -1030,8 +1035,8 @@ jobs: if-no-files-found: ignore release: - name: Draft GitHub release - if: startsWith(github.ref, 'refs/tags/v') + name: Publish verified installer assets + if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish_candidate) }} needs: [backend-tests, frontend-quality, rust-security-audit, browser-smoke, reliability-failure-recovery, critical-browser-repeatability, migration-browser-smoke, desktop-package, desktop-installed-smoke, macos-package] runs-on: ubuntu-latest permissions: @@ -1040,7 +1045,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Read artifact version + id: artifact_version + shell: bash + run: | + python - <<'PY' + import json, os + from pathlib import Path + version = json.loads(Path("frontend/package.json").read_text())["version"] + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"version={version}\n") + PY + - name: Enforce Public Release readiness before publishing + if: startsWith(github.ref, 'refs/tags/v') run: python backend/scripts/release/audit_release_readiness.py --repo-root . --require-ready - name: Download Windows release artifact @@ -1059,9 +1077,14 @@ jobs: shell: bash run: | set -euo pipefail - expected_version="${GITHUB_REF_NAME#v}" + expected_version="${{ steps.artifact_version.outputs.version }}" + signed_arguments=() + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + expected_version="${GITHUB_REF_NAME#v}" + signed_arguments=(--require-signed) + fi for artifact in release-assets/windows-x64 release-assets/offeru-macos-arm64 release-assets/offeru-macos-x64; do - python backend/scripts/release/verify_release_artifacts.py "$artifact" --expected-version "$expected_version" --require-signed + python backend/scripts/release/verify_release_artifacts.py "$artifact" --expected-version "$expected_version" "${signed_arguments[@]}" platform=$(basename "$artifact") cp "$artifact/artifacts.json" "release-assets/artifacts-$platform.json" cp "$artifact/version.json" "release-assets/version-$platform.json" @@ -1074,11 +1097,16 @@ jobs: set -euo pipefail python backend/scripts/release/audit_artifacts.py release-assets --json - - name: Create draft release + - name: Publish installer assets uses: softprops/action-gh-release@v2 with: - name: OfferU ${{ github.ref_name }} - draft: true + tag_name: ${{ github.event_name == 'workflow_dispatch' && format('dogfood-{0}-{1}', steps.artifact_version.outputs.version, github.run_number) || github.ref_name }} + target_commitish: ${{ github.sha }} + name: ${{ github.event_name == 'workflow_dispatch' && format('OfferU {0} Owner Dogfood', steps.artifact_version.outputs.version) || format('OfferU {0}', github.ref_name) }} + draft: ${{ github.event_name != 'workflow_dispatch' }} + prerelease: ${{ github.event_name == 'workflow_dispatch' }} + make_latest: false + body: ${{ github.event_name == 'workflow_dispatch' && 'Owner Dogfood prerelease. Installers are built and tested by GitHub Actions from the target commit. Windows installers are unsigned; full Public Release readiness and human product acceptance remain outstanding. This is not a stable public release.' || '' }} generate_release_notes: true files: | release-assets/windows-x64/*.exe diff --git a/backend/app/services/agentic_interaction_policy.py b/backend/app/services/agentic_interaction_policy.py index f5d1e773..07828a82 100644 --- a/backend/app/services/agentic_interaction_policy.py +++ b/backend/app/services/agentic_interaction_policy.py @@ -1,10 +1,8 @@ """Project Plan execution facts into the user's interaction states. -This module does not authorize or execute Operations. Review-card quality is -advisory: missing Before/After/Why/evidence/source metadata is shown as a -warning but does not deadlock an otherwise valid decision. Hard blocking is -reserved for stale snapshots, unknown execution state, explicit policy blocks -and the existing destructive/external authorization boundary. +This module does not authorize or execute Operations. It projects review +quality as advisory and retains hard consistency and execution-state blockers; +the Proposal Plan and Operation Registry still enforce execution authority. """ from __future__ import annotations @@ -71,8 +69,11 @@ def _review_packet_reasons(group: Mapping[str, Any]) -> list[str]: has_before = any("before" in item and _present(item.get("before")) for item in displays) has_after = any("after" in item and _present(item.get("after")) for item in displays) if changes: - has_before = has_before or all("before" in item and _present(item.get("before")) for item in changes if isinstance(item, Mapping)) - has_after = has_after or all("after" in item and _present(item.get("after")) for item in changes if isinstance(item, Mapping)) + # A change row states its own before/after. An explicit empty value is + # a real state (an added row has no before, a removed row no after), + # so the key being present is what makes the row reviewable. + has_before = has_before or all("before" in item for item in changes) + has_after = has_after or all("after" in item for item in changes) if not has_before: reasons.append("missing_before") if not has_after: @@ -80,7 +81,15 @@ def _review_packet_reasons(group: Mapping[str, Any]) -> list[str]: if not any(_present(item.get(key)) for item in displays for key in ("why", "rationale")): if not any(isinstance(change, Mapping) and any(_present(change.get(key)) for key in ("why", "rationale")) for change in changes): reasons.append("missing_why") - if not any(_display_sources(item) for item in displays): + # Evidence is required where the packet asserts content the user did not + # write: AI rewrites shown as change rows, which the plan source builder + # backs with per-change evidence. Direct Registry edits (triage a job, + # set a headline, add a target role, create a workspace) are reviewed + # against the pinned current source instead. The builder never attaches + # evidence to them, so demanding it left those groups permanently + # "needs_preparation": the user could neither confirm nor fix them. + needs_evidence = bool(changes) + if needs_evidence and not any(_display_sources(item) for item in displays): reasons.append("missing_evidence") if not _source_versions(group): reasons.append("missing_current_source") @@ -94,10 +103,7 @@ def _requested_outcome(group: Mapping[str, Any]) -> str: outcome = str(display.get("interaction_outcome") or "").strip().upper() if outcome: return outcome - # L1/prepare used to default to AUTO, but there is no universal executor - # consuming those groups. Surface them as ordinary review decisions so the - # user always has an exit instead of leaving a durable Run parked forever. - return "REVIEW" + return "AUTO" if group.get("risk") == "prepare" else "REVIEW" def assess_group(group: Mapping[str, Any], *, source_current: bool | None = True) -> dict[str, Any]: @@ -118,7 +124,16 @@ def assess_group(group: Mapping[str, Any], *, source_current: bool | None = True return {"reviewability": {"status": "needs_preparation", "reason_codes": ["group_not_pending"], "counts_as_user_decision": False}, "interaction_state": "system_recovering"} - warnings = _review_packet_reasons(group) + # Packet completeness (before/after/why/evidence/pinned source) is + # advisory: it is shown on the card as a hint and never blocks a decision. + # Blocking on it left Registry groups permanently "needs_preparation" with + # no way for the user to confirm or fix them. Hard safety still blocks: + # stale sources, unknown execution state (above), invalid/BLOCK outcomes, + # unbound Ask, and an AUTHORIZE group without an affected scope. + advisory = _review_packet_reasons(group) + blocking: list[str] = [] + if source_current is False: + blocking.append("source_changed_or_unavailable") operations = {str(node.get("operation") or "") for node in nodes} risk = str(group.get("risk") or "") @@ -126,48 +141,27 @@ def assess_group(group: Mapping[str, Any], *, source_current: bool | None = True if outcome not in {"AUTO", "ASK", "REVIEW", "AUTHORIZE", "BLOCK"}: return {"reviewability": {"status": "needs_preparation", "reason_codes": ["invalid_interaction_outcome"], "counts_as_user_decision": False}, "interaction_state": "system_blocked"} - - # Canonical source freshness is a real safety invariant. Metadata being - # absent is only a quality warning; a source that was captured and then - # changed is a stale-snapshot blocker and must be regenerated. - if source_current is False: - return {"reviewability": {"status": "needs_preparation", "reason_codes": ["source_changed_or_unavailable"], "counts_as_user_decision": False}, - "interaction_state": "system_recovering"} - - destructive = bool(operations & _DESTRUCTIVE_OPERATIONS) - if risk == "external" or destructive: + if risk == "external" or operations & _DESTRUCTIVE_OPERATIONS: outcome = "AUTHORIZE" - - # A destructive action must still identify its affected scope before the - # user can authorize it. Other review-card omissions are warnings only. - if destructive and "missing_scope" in warnings: - return {"reviewability": {"status": "needs_preparation", "reason_codes": ["missing_scope"], "counts_as_user_decision": False}, - "interaction_state": "system_blocked"} - + if outcome == "AUTHORIZE" and "missing_scope" in advisory: + blocking.append("missing_scope") + if blocking: + return {"reviewability": {"status": "needs_preparation", "reason_codes": sorted(set(blocking)), "counts_as_user_decision": False}, + "interaction_state": "system_recovering"} if outcome == "BLOCK": return {"reviewability": {"status": "needs_preparation", "reason_codes": ["policy_blocked"], "counts_as_user_decision": False}, "interaction_state": "system_blocked"} - - warning_codes = sorted(set(warnings)) if outcome == "ASK": - # If the caller forgot to persist a dedicated AgentInputRequest, keep - # the group actionable instead of parking the Run forever. Approval is - # explicit and still binds the exact immutable Plan/Group digests. - warning_codes = sorted(set([*warning_codes, "input_request_not_bound_to_group"])) - outcome = "REVIEW" - + # Ask is projected from the separately persisted AgentInputRequest; + # a Plan display hint cannot manufacture an actionable user question. + return {"reviewability": {"status": "needs_preparation", "reason_codes": ["input_request_not_bound_to_group"], "counts_as_user_decision": False}, + "interaction_state": "system_recovering"} + ready = {"status": "ready", "reason_codes": [], "advisory_codes": sorted(set(advisory)), "counts_as_user_decision": True} if outcome == "AUTHORIZE": - return {"reviewability": {"status": "ready", "reason_codes": warning_codes, "counts_as_user_decision": True}, - "interaction_state": "needs_user_authorization"} - if outcome in {"REVIEW", "AUTO"}: - # AUTO is also reviewable as a fallback. A missing autonomous executor - # must never make a durable operation unreachable from the UI. - if outcome == "AUTO": - warning_codes = sorted(set([*warning_codes, "auto_fallback_requires_confirmation"])) - return {"reviewability": {"status": "ready", "reason_codes": warning_codes, "counts_as_user_decision": True}, - "interaction_state": "needs_user_review"} - return {"reviewability": {"status": "ready", "reason_codes": warning_codes, "counts_as_user_decision": True}, - "interaction_state": "needs_user_review"} + return {"reviewability": ready, "interaction_state": "needs_user_authorization"} + # REVIEW, and AUTO: nothing runs AUTO groups on its own yet, so a pending + # prepare group is shown for a one-tap confirm instead of stalling unseen. + return {"reviewability": ready, "interaction_state": "needs_user_review"} def run_interaction_state(run: Mapping[str, Any]) -> str: diff --git a/backend/app/services/proposal_plan_execution.py b/backend/app/services/proposal_plan_execution.py index d4b120b0..5bec1865 100644 --- a/backend/app/services/proposal_plan_execution.py +++ b/backend/app/services/proposal_plan_execution.py @@ -759,16 +759,20 @@ async def _decide_group( # the reviewability gate below applies to NEW decisions only, because # a decided group legitimately no longer looks pending. replayed_decision = await _existing_decision(group) - if replayed_decision is None: + # Rejecting is always a way out of a pending group: it runs nothing, + # so readiness (stale source, unbound Ask, missing scope...) only + # gates approval. Otherwise a group that cannot be prepared would + # sit in the Run forever with no exit. + if replayed_decision is None and decision in {"approve", "reject"}: from app.services.agentic_interaction_policy import assess_group readiness = assess_group(group) - if readiness["reviewability"]["status"] != "ready": + if decision == "reject" and readiness["reviewability"]["status"] == "needs_reconciliation": + raise PlanValidationError("Cannot reject while an Operation effect is unknown; reconcile it first") + if decision == "approve" and readiness["reviewability"]["status"] != "ready": raise PlanValidationError( "Review packet needs preparation: " + ", ".join(readiness["reviewability"]["reason_codes"]) ) - if not readiness["reviewability"]["counts_as_user_decision"]: - raise PlanValidationError("This Operation is not waiting for a user decision") except PlanValidationError as exc: return {**empty, "errors": [str(exc)]} @@ -786,7 +790,7 @@ async def _decide_group( "surface": surface, } try: - if await _existing_decision(group) is None: + if decision == "approve" and await _existing_decision(group) is None: from app.services.proposal_plan_sources import validate_source_versions await validate_source_versions(_group_source_versions(group)) @@ -812,7 +816,16 @@ async def _decide_group( if current_plan is None or current_group is None: return {**empty, "errors": ["持久化决定已记录,但无法读取其确认组。"], "duplicate": duplicate} if decision == "reject": - continuation = await _continuation_for_group(current_plan, current_group) + # record_decision creates a continuation only for a live Run. Read + # that persisted row without calling _continuation_for_group, which + # creates one when absent and could revive a terminal Run. + run_id = str(current_plan.get("run_id") or "") + group_id = str(current_group.get("id") or "") + continuation = next( + (item for item in await list_continuations(run_id=run_id) + if isinstance(item, dict) and str(item.get("group_id") or "") == group_id), + None, + ) return { "ok": str(current_group.get("status") or "") == "rejected", "plan": current_plan, diff --git a/backend/app/services/proposal_plan_store.py b/backend/app/services/proposal_plan_store.py index 9b0baf45..36ad574c 100644 --- a/backend/app/services/proposal_plan_store.py +++ b/backend/app/services/proposal_plan_store.py @@ -941,7 +941,12 @@ async def record_decision(decision: Mapping[str, Any]) -> dict[str, Any]: raise ProposalDecisionConflictError("Decision event_id was reused") plan, group, ps, gs = await _plan_group(db, value["plan_id"], value["group_id"]) run = await db.get(AgentRunRecord, plan.run_id) - if run is None or run.status in {"cancelled", "failed", "completed", "needs_reconciliation"}: + # Approval needs a live Run. Rejection runs nothing, so it stays + # available after the Run ended: otherwise its leftover pending + # groups could never leave the user's Inbox. A Run whose effects + # are unknown (needs_reconciliation) is still refused. + ended = {"cancelled", "failed", "completed"} if value["decision"] == "approve" else set() + if run is None or run.status in ended | {"needs_reconciliation"}: raise ProposalDecisionConflictError("The originating Run is no longer active") if plan.status not in {"sealed", "executing"}: raise ProposalDecisionConflictError(f"Plan is not reviewable while {plan.status}") @@ -975,7 +980,8 @@ async def record_decision(decision: Mapping[str, Any]) -> dict[str, Any]: if node.status == "pending": node.status = "rejected" await _block_descendants(db, plan.id, group.id) - await _ensure_continuation(db, plan.run_id, group.id) + if run.status not in {"cancelled", "failed", "completed", "needs_reconciliation"}: + await _ensure_continuation(db, plan.run_id, group.id) await _update_plan_status(db, plan.id) return {"decision": _decision_dto(row), "duplicate": False} except IntegrityError as exc: diff --git a/backend/scripts/e2e/test_public_release_empty_states.py b/backend/scripts/e2e/test_public_release_empty_states.py index fec5b9e9..ee929f5e 100644 --- a/backend/scripts/e2e/test_public_release_empty_states.py +++ b/backend/scripts/e2e/test_public_release_empty_states.py @@ -78,9 +78,32 @@ def main() -> None: raise AssertionError(f"Profile empty fact guidance missing: {required!r}") _open(page, "/resume") - expect(page.get_by_text("暂无简历草稿", exact=True)).to_be_visible(timeout=20000) - expect(page.get_by_role("button", name="新建简历", exact=True)).to_be_visible() - expect(page.get_by_text("随时创建新版本", exact=True)).to_be_visible() + expect(page.get_by_text("还没有简历", exact=True)).to_be_visible(timeout=20000) + expect( + page.get_by_text( + "上传一份现有简历最快,OfferU 会把内容拆进档案,由你确认后再使用;也可以从空白开始。", + exact=True, + ) + ).to_be_visible() + new_resume_button = page.get_by_role("button", name="新建简历", exact=True).last + expect(new_resume_button).to_be_visible() + new_resume_button.click() + create_dialog = page.get_by_role("dialog") + expect(create_dialog).to_be_visible() + expect(create_dialog.get_by_role("heading", name="新建简历", exact=True)).to_be_visible() + create_dialog.get_by_role("button", name="创建并编辑", exact=True).click() + page.wait_for_function( + "() => /^#\\/resume\\/\\d+/.test(window.location.hash)", + timeout=20000, + ) + expect(page.get_by_test_id("resume-name-input")).to_be_visible(timeout=20000) + expect(page.get_by_test_id("resume-save-version")).to_be_visible() + expect(page.get_by_test_id("resume-workspace-error")).to_have_count(0) + expect(page.get_by_role("alert")).to_have_count(0) + resume_text = page.locator("body").inner_text().lower() + raw_json_markers = ('"raw_json"', '"rawjson"', '{"id":', '"resume_id":') + if any(marker in resume_text for marker in raw_json_markers): + raise AssertionError("Resume workspace rendered raw API JSON") unexpected_responses = [ response @@ -103,7 +126,8 @@ def main() -> None: "Opportunity offers first-job action", "Pipeline explains how to create the first application", "Profile exposes first-facts onboarding", - "Resume exposes first-version action", + "Resume empty state opens the editor through its create action", + "Resume workspace exposes no raw API JSON or error state", ], "bad_responses": unexpected_responses, "console_errors": console_errors, diff --git a/backend/scripts/e2e/test_public_release_worker_soak.py b/backend/scripts/e2e/test_public_release_worker_soak.py index 74192eb1..d2f4abb4 100644 --- a/backend/scripts/e2e/test_public_release_worker_soak.py +++ b/backend/scripts/e2e/test_public_release_worker_soak.py @@ -26,6 +26,8 @@ 5.0, float(os.getenv("OFFERU_WORKER_SOAK_TASK_TIMEOUT_SECONDS", "60")), ) +JOB_PAGE_SIZE = 100 +TERMINAL_TASK_STATUSES = {"completed", "failed", "blocked", "cancelled"} def _request(client: httpx.Client, method: str, path: str, **kwargs: Any) -> dict[str, Any]: @@ -84,6 +86,84 @@ def _job_payload(run_key: str, index: int) -> dict[str, Any]: } +def _list_jobs_for_run(client: httpx.Client, run_key: str) -> list[dict[str, Any]]: + """Read this run's Jobs through the public list view, independent of unrelated rows.""" + + keyword = f"Worker Orbit {run_key}-" + jobs: list[dict[str, Any]] = [] + total: int | None = None + page = 1 + + while total is None or len(jobs) < total: + payload = _request( + client, + "GET", + "/api/jobs/", + params={"keyword": keyword, "page": page, "page_size": JOB_PAGE_SIZE}, + ) + page_total = payload.get("total") + page_items = payload.get("items") + if not isinstance(page_total, int) or not isinstance(page_items, list): + raise AssertionError(f"Job list returned an invalid page: {payload}") + if total is not None and page_total != total: + raise AssertionError( + f"Job list total changed during pagination: {total} -> {page_total}" + ) + if any(not isinstance(item, dict) for item in page_items): + raise AssertionError(f"Job list returned a non-object item: {page_items}") + total = page_total + if not page_items and len(jobs) < total: + raise AssertionError( + f"Job list page {page} was empty before reaching total {total}" + ) + jobs.extend(page_items) + page += 1 + + return jobs + + +def _wait_for_role_tasks( + client: httpx.Client, + job_target_ids: set[str], +) -> list[dict[str, Any]]: + deadline = time.monotonic() + TASK_TIMEOUT_SECONDS + while time.monotonic() < deadline: + payload = _request( + client, + "GET", + "/api/agent/runtime/career-tasks?task_type=role_intelligence&limit=200", + ) + tasks = payload.get("tasks", []) + if not isinstance(tasks, list): + raise AssertionError(f"Role Intelligence task list is invalid: {payload}") + matching = [ + item + for item in tasks + if isinstance(item, dict) and str(item.get("target_id")) in job_target_ids + ] + target_counts: dict[str, int] = {} + for item in matching: + target_id = str(item.get("target_id")) + target_counts[target_id] = target_counts.get(target_id, 0) + 1 + duplicate_targets = sorted( + target_id for target_id, count in target_counts.items() if count > 1 + ) + if duplicate_targets: + raise AssertionError( + f"duplicate Role Intelligence tasks for Jobs: {duplicate_targets}" + ) + if ( + set(target_counts) == job_target_ids + and all(item.get("status") in TERMINAL_TASK_STATUSES for item in matching) + ): + return matching + time.sleep(POLL_SECONDS) + raise AssertionError( + f"Role Intelligence tasks did not reach terminal states for " + f"{len(job_target_ids)} Jobs; found {len(target_counts)} targets" + ) + + def main() -> None: run_key = str(int(time.time() * 1000)) started_at = time.perf_counter() @@ -146,17 +226,23 @@ def main() -> None: if index % 10 == 0 or index == CYCLES: print(f"worker cycles: {index}/{CYCLES}", flush=True) - jobs = _request(client, "GET", "/api/jobs/?page_size=100") - matching_jobs = [ - item - for item in jobs.get("items", []) - if isinstance(item, dict) - and str(item.get("company") or "").startswith(f"Worker Orbit {run_key}-") - ] + matching_jobs = _list_jobs_for_run(client, run_key) if len(matching_jobs) != CYCLES: raise AssertionError( f"expected {CYCLES} worker Jobs, found {len(matching_jobs)}" ) + expected_job_ids = {str(job_id) for job_id in job_ids} + listed_job_ids = [str(item.get("id")) for item in matching_jobs] + if len(set(listed_job_ids)) != CYCLES or set(listed_job_ids) != expected_job_ids: + raise AssertionError( + "worker Job listing did not return each created Job exactly once: " + f"expected={len(expected_job_ids)}, actual={len(set(listed_job_ids))}" + ) + if any( + not str(item.get("company") or "").startswith(f"Worker Orbit {run_key}-") + for item in matching_jobs + ): + raise AssertionError("worker Job listing included a Job outside this run") tasks = _request( client, @@ -173,22 +259,6 @@ def main() -> None: f"expected {CYCLES} unique persisted tasks, found {len(matching_tasks)}" ) - role_tasks = _request( - client, - "GET", - "/api/agent/runtime/career-tasks?task_type=role_intelligence&limit=200", - ) - unexpected_role_tasks = [ - item - for item in role_tasks.get("tasks", []) - if isinstance(item, dict) - and str(item.get("target_id")) in job_target_ids - ] - if unexpected_role_tasks: - raise AssertionError( - f"JOB_SAVED must not auto-start Role Intelligence: {unexpected_role_tasks}" - ) - automation = _request( client, "GET", @@ -204,6 +274,96 @@ def main() -> None: raise AssertionError( f"expected {CYCLES} unique JOB_SAVED events, found {len(matching_events)}" ) + events_by_job: dict[str, dict[str, Any]] = {} + for event in matching_events: + target_id = str(event.get("target_id") or "") + if target_id in events_by_job: + raise AssertionError(f"Job {target_id} has duplicate JOB_SAVED events") + if event.get("status") != "dispatched": + raise AssertionError( + f"JOB_SAVED event for Job {target_id} was not durably dispatched" + ) + events_by_job[target_id] = event + if set(events_by_job) != job_target_ids: + raise AssertionError("JOB_SAVED events do not cover every worker Job exactly once") + + role_tasks = _wait_for_role_tasks(client, job_target_ids) + if len(role_tasks) != CYCLES: + raise AssertionError( + f"expected one Role Intelligence task per Job, found {len(role_tasks)}" + ) + role_task_ids: set[str] = set() + role_task_statuses: list[str] = [] + for task in role_tasks: + target_id = str(task.get("target_id") or "") + event = events_by_job[target_id] + task_input = task.get("input") if isinstance(task.get("input"), dict) else {} + task_id = str(task.get("task_id") or "") + if not task_id or task_id in role_task_ids: + raise AssertionError("Role Intelligence tasks did not have unique task IDs") + role_task_ids.add(task_id) + if task.get("task_type") != "role_intelligence" or task.get("source") != "automation": + raise AssertionError(f"Job {target_id} has an unexpected Role Intelligence task: {task}") + if task.get("runtime_provider") != "replay": + raise AssertionError( + f"Role Intelligence task {task_id} used an unexpected provider" + ) + if ( + str(task_input.get("job_id") or "") != target_id + or task_input.get("automation_event_id") != event.get("event_id") + ): + raise AssertionError( + f"Role Intelligence task {task_id} is not bound to its JOB_SAVED event" + ) + event_result = event.get("result") if isinstance(event.get("result"), dict) else {} + event_role_task = event_result.get("role_intelligence_task") + if not isinstance(event_role_task, dict) or event_role_task.get("task_id") != task_id: + raise AssertionError( + f"JOB_SAVED event {event.get('event_id')} lacks the Role Intelligence task receipt" + ) + + status = str(task.get("status") or "") + role_task_statuses.append(status) + attempt_count = int(task.get("attempt_count") or 0) + max_attempts = int(task.get("max_attempts") or 0) + if max_attempts < 1 or attempt_count > max_attempts: + raise AssertionError(f"Role Intelligence task {task_id} exceeded its retry bound") + if status == "completed": + result = task.get("result") if isinstance(task.get("result"), dict) else {} + benchmark = result.get("benchmark") if isinstance(result.get("benchmark"), dict) else {} + if ( + int(result.get("job_id") or 0) != int(target_id) + or not result.get("benchmark_run_id") + or benchmark.get("status") != "completed" + or task.get("result_ref") != f"career-task:{task_id}" + or attempt_count < 1 + or task.get("retryable") + ): + raise AssertionError( + f"Role Intelligence task {task_id} lacks a completed benchmark receipt" + ) + elif status == "blocked": + error = str(task.get("error") or "").casefold() + unavailable_markers = ( + "provider", + "runtime", + "credential", + "unavailable", + "not configured", + "authentication", + ) + if ( + not task.get("retryable") + or not error + or not any(marker in error for marker in unavailable_markers) + ): + raise AssertionError( + f"Role Intelligence task {task_id} is blocked without a retryable provider reason" + ) + else: + raise AssertionError( + f"Role Intelligence task {task_id} ended in unexpected state {status!r}" + ) integrity = _request(client, "GET", "/api/agent/data/safety/integrity") integrity_check = integrity.get("integrity_check") @@ -221,12 +381,14 @@ def main() -> None: "unique_tasks": len(set(task_ids)), "unique_automation_events": len(matching_events), "task_statuses": sorted(set(statuses)), + "role_intelligence_statuses": sorted(set(role_task_statuses)), "task_attempts": 1, "database_integrity": integrity_check, "foreign_key_violations": foreign_key_violations, "elapsed_seconds": round(time.perf_counter() - started_at, 3), - "runtime_provider": "pi", - "role_intelligence_tasks": len(unexpected_role_tasks), + "role_intelligence_runtime_provider": "replay", + "career_director_runtime_provider": "embedded", + "role_intelligence_tasks": len(role_tasks), } print(json.dumps(result, ensure_ascii=True, indent=2), flush=True) diff --git a/backend/tests/test_agentic_interaction_policy.py b/backend/tests/test_agentic_interaction_policy.py index bc0489eb..0a036312 100644 --- a/backend/tests/test_agentic_interaction_policy.py +++ b/backend/tests/test_agentic_interaction_policy.py @@ -33,67 +33,82 @@ def test_complete_packet_is_user_review_and_external_effect_keeps_authorization_ })) assert review["reviewability"] == { - "status": "ready", "reason_codes": [], "counts_as_user_decision": True, + "status": "ready", "reason_codes": [], "advisory_codes": [], "counts_as_user_decision": True, } assert review["interaction_state"] == "needs_user_review" assert authorize["interaction_state"] == "needs_user_authorization" assert authorize["reviewability"]["counts_as_user_decision"] is True -def test_incomplete_packet_is_warning_but_unknown_execution_still_blocks(): +def test_incomplete_packet_is_advisory_and_unknown_execution_is_system_work(): incomplete = assess_group(_group(display={"before": "Old", "after": "New"})) uncertain = assess_group(_group(nodes=[{"operation": "update_resume_section", "status": "uncertain"}])) + # Missing why/evidence is a hint on the card, never a lock on the decision. assert incomplete["reviewability"]["status"] == "ready" assert incomplete["reviewability"]["counts_as_user_decision"] is True - assert {"missing_why", "missing_evidence"}.issubset(incomplete["reviewability"]["reason_codes"]) + assert "missing_why" in incomplete["reviewability"]["advisory_codes"] assert incomplete["interaction_state"] == "needs_user_review" assert uncertain["reviewability"]["status"] == "needs_reconciliation" assert uncertain["interaction_state"] == "system_recovering" -def test_prepare_and_unbound_ask_always_have_a_user_exit(): - preparation = assess_group(_group(risk="prepare")) - ask = assess_group(_group(display={**_group()["display"], "interaction_outcome": "ASK"})) - explicit_auto = assess_group(_group(display={**_group()["display"], "interaction_outcome": "AUTO"})) - - assert preparation["reviewability"]["status"] == "ready" - assert preparation["reviewability"]["counts_as_user_decision"] is True - assert preparation["interaction_state"] == "needs_user_review" - assert ask["interaction_state"] == "needs_user_review" - assert ask["reviewability"]["counts_as_user_decision"] is True - assert "input_request_not_bound_to_group" in ask["reviewability"]["reason_codes"] - assert explicit_auto["interaction_state"] == "needs_user_review" - assert "auto_fallback_requires_confirmation" in explicit_auto["reviewability"]["reason_codes"] +def test_ai_change_rows_flag_missing_evidence_but_direct_registry_edits_do_not(): + unsourced_rewrite = assess_group(_group(display={ + "changes": [{"before": "Old bullet", "after": "New bullet", "why": "Match the role"}], + })) + direct_edit = assess_group(_group(display={ + "before": {"headline": "Before"}, "after": {"headline": "After"}, "rationale": "User asked to revise it", + })) + removed_row = assess_group(_group(display={ + "changes": [{"before": "Old bullet", "after": None, "why": "Not true", "evidence_refs": ["resume:1/section:4"]}], + })) + assert unsourced_rewrite["reviewability"]["advisory_codes"] == ["missing_evidence"] + assert unsourced_rewrite["reviewability"]["status"] == "ready" + assert direct_edit["reviewability"]["advisory_codes"] == [] + assert removed_row["reviewability"]["advisory_codes"] == [] -def test_missing_source_metadata_warns_but_captured_stale_source_still_blocks(): - missing = _group(source_versions={}, nodes=[{"operation": "update_resume_section", "status": "pending"}]) - advisory = assess_group(missing) +def test_missing_source_metadata_is_advisory_but_a_captured_source_conflict_blocks(): + missing = assess_group(_group( + source_versions={}, + nodes=[{"operation": "update_resume_section", "status": "pending"}], + )) stale = assess_group(_group(), source_current=False) - assert advisory["reviewability"]["status"] == "ready" - assert advisory["reviewability"]["counts_as_user_decision"] is True - assert "missing_current_source" in advisory["reviewability"]["reason_codes"] + assert missing["reviewability"]["status"] == "ready" + assert missing["reviewability"]["advisory_codes"] == ["missing_current_source"] assert stale["reviewability"]["status"] == "needs_preparation" assert stale["reviewability"]["counts_as_user_decision"] is False assert stale["reviewability"]["reason_codes"] == ["source_changed_or_unavailable"] -def test_destructive_operation_keeps_explicit_authorization_and_scope_floor(): - group = _group( +def test_prepare_is_one_tap_review_and_unbound_ask_is_not_a_decision(): + automatic = assess_group(_group(risk="prepare")) + ask = assess_group(_group(display={**_group()["display"], "interaction_outcome": "ASK"})) + + # Nothing auto-runs prepare groups yet, so they surface for one-tap confirm. + assert automatic["reviewability"]["status"] == "ready" + assert automatic["reviewability"]["counts_as_user_decision"] is True + assert automatic["interaction_state"] == "needs_user_review" + assert ask["interaction_state"] == "system_recovering" + assert ask["reviewability"]["reason_codes"] == ["input_request_not_bound_to_group"] + + +def test_destructive_operations_keep_authorization_and_affected_scope_floor(): + reset = _group( risk="prepare", scope="", display={"before": "all local data", "after": "empty workspace"}, nodes=[{"operation": "reset_local_business_data", "status": "pending"}], ) - blocked = assess_group(group) - group["scope"] = "local_business_data" - authorized = assess_group(group) + missing_scope = assess_group(reset) + reset["scope"] = "local_business_data" + authorized = assess_group(reset) - assert blocked["interaction_state"] == "system_blocked" - assert blocked["reviewability"]["reason_codes"] == ["missing_scope"] + assert missing_scope["reviewability"]["status"] == "needs_preparation" + assert missing_scope["reviewability"]["reason_codes"] == ["missing_scope"] assert authorized["interaction_state"] == "needs_user_authorization" assert authorized["reviewability"]["counts_as_user_decision"] is True diff --git a/backend/tests/test_proposal_v2_fault_acceptance.py b/backend/tests/test_proposal_v2_fault_acceptance.py index 69a67aba..e3ed428f 100644 --- a/backend/tests/test_proposal_v2_fault_acceptance.py +++ b/backend/tests/test_proposal_v2_fault_acceptance.py @@ -29,7 +29,7 @@ from contextlib import closing from dataclasses import replace from pathlib import Path -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest @@ -129,6 +129,12 @@ async def _confirm(plan: dict, group: dict, *, decision_id: str | None = None) - return await confirm_group(plan["id"], group["id"], **_confirm_args(plan, group, decision_id)) +async def _reject(plan: dict, group: dict, *, decision_id: str | None = None) -> dict: + from app.services.proposal_plan_execution import reject_group + + return await reject_group(plan["id"], group["id"], **_confirm_args(plan, group, decision_id)) + + async def _audit_rows(database, nodes: list[dict]) -> list[OperationAuditLog]: keys = [node["idempotency_key"] for node in nodes] async with database.sessions() as db: @@ -172,6 +178,100 @@ async def _record_test_approval(plan: dict, group: dict, decision_id: str) -> di ) +def test_rejecting_an_ended_run_clears_the_group_without_freshness_or_continuation( + proposal_v2_db, monkeypatch +): + async def run(): + from app.models.models import AgentRunRecord + from app.services import proposal_plan_sources, proposal_plan_store + + _permit_test_ui_capability(monkeypatch) + _seed, _intents, _groups, run_id, plan = await _scenario(proposal_v2_db) + group = _five_create_group(plan) + async with proposal_v2_db.sessions() as db: + run_row = await db.get(AgentRunRecord, run_id) + assert run_row is not None + run_row.status = "completed" + await db.commit() + + validate_sources = AsyncMock(side_effect=ValueError("source changed")) + with patch.object(proposal_plan_sources, "validate_source_versions", validate_sources): + result = await _reject(plan, group) + + validate_sources.assert_not_awaited() + assert result["ok"] is True, result + assert result["continuation"] is None + assert result["receipts"] == [] + assert await proposal_plan_store.list_continuations(run_id=run_id) == [] + assert await _audit_rows(proposal_v2_db, group["nodes"]) == [] + latest = await proposal_plan_store.get_plan(plan["id"]) + rejected = next(item for item in latest["groups"] if item["id"] == group["id"]) + assert rejected["status"] == "rejected" + + asyncio.run(run()) + + +def test_rejection_is_not_gated_by_the_user_decision_count(proposal_v2_db, monkeypatch): + async def run(): + from app.services import proposal_plan_store + + _permit_test_ui_capability(monkeypatch) + _seed, _intents, _groups, run_id, plan = await _scenario(proposal_v2_db) + group = _five_create_group(plan) + unprepared = { + "reviewability": { + "status": "needs_preparation", + "reason_codes": ["missing_scope"], + "counts_as_user_decision": False, + }, + "interaction_state": "system_blocked", + } + with patch("app.services.agentic_interaction_policy.assess_group", return_value=unprepared): + result = await _reject(plan, group) + + assert result["ok"] is True, result + assert result["group"]["status"] == "rejected" + assert await proposal_plan_store.list_continuations(run_id=run_id) + assert await _audit_rows(proposal_v2_db, group["nodes"]) == [] + + + asyncio.run(run()) + + +@pytest.mark.parametrize("unknown_at", ["run", "operation"]) +def test_reject_does_not_clear_unknown_execution_state( + proposal_v2_db, monkeypatch, unknown_at +): + async def run(): + from app.models.models import AgentRunRecord, ProposalOperationNode + from app.services import proposal_plan_store + + _permit_test_ui_capability(monkeypatch) + _seed, _intents, _groups, run_id, plan = await _scenario(proposal_v2_db) + group = _five_create_group(plan) + async with proposal_v2_db.sessions() as db: + run_row = await db.get(AgentRunRecord, run_id) + assert run_row is not None + if unknown_at == "run": + run_row.status = "needs_reconciliation" + else: + node = await db.get(ProposalOperationNode, group["nodes"][0]["id"]) + assert node is not None + node.status = "uncertain" + await db.commit() + + result = await _reject(plan, group) + assert result["ok"] is False + assert result["errors"] + assert await proposal_plan_store.list_continuations(run_id=run_id) == [] + latest = await proposal_plan_store.get_plan(plan["id"]) + still_pending = next(item for item in latest["groups"] if item["id"] == group["id"]) + assert still_pending["status"] == "pending" + assert await _audit_rows(proposal_v2_db, group["nodes"]) == [] + + asyncio.run(run()) + + @pytest.mark.parametrize("field", ["args", "schema", "source", "display", "digest"]) def test_sealed_snapshot_rejects_tampered_material(tmp_path, monkeypatch, field): async def run(): diff --git a/backend/tests/test_public_release_worker_soak.py b/backend/tests/test_public_release_worker_soak.py new file mode 100644 index 00000000..b6825b28 --- /dev/null +++ b/backend/tests/test_public_release_worker_soak.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + + +def _load_worker_soak(monkeypatch: Any): + e2e_dir = Path(__file__).resolve().parents[1] / "scripts" / "e2e" + monkeypatch.syspath_prepend(str(e2e_dir)) + script_path = e2e_dir / "test_public_release_worker_soak.py" + spec = importlib.util.spec_from_file_location("offeru_worker_soak_script", script_path) + if spec is None or spec.loader is None: + raise AssertionError(f"could not load worker soak script: {script_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_worker_job_listing_filters_this_run_and_reads_every_page(monkeypatch: Any) -> None: + worker_soak = _load_worker_soak(monkeypatch) + run_key = "1791512369920" + expected_items = [{"id": index, "company": f"Worker Orbit {run_key}-{index:03d}"} for index in range(1, 102)] + requests: list[dict[str, Any]] = [] + + def fake_request(_client: Any, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + params = kwargs["params"] + requests.append({"method": method, "path": path, "params": params}) + start = (params["page"] - 1) * params["page_size"] + return {"total": len(expected_items), "items": expected_items[start : start + params["page_size"]]} + + monkeypatch.setattr(worker_soak, "_request", fake_request) + + jobs = worker_soak._list_jobs_for_run(object(), run_key) + + assert [job["id"] for job in jobs] == list(range(1, 102)) + assert [request["params"]["page"] for request in requests] == [1, 2] + assert all(request["method"] == "GET" and request["path"] == "/api/jobs/" for request in requests) + assert all(request["params"]["keyword"] == f"Worker Orbit {run_key}-" for request in requests) diff --git a/docs/04-module-job-workspace.md b/docs/04-module-job-workspace.md index c5486822..152530f8 100644 --- a/docs/04-module-job-workspace.md +++ b/docs/04-module-job-workspace.md @@ -51,13 +51,13 @@ ```text JOB_SAVED - → Career Director 做一次有边界的评估(读取当前职业状态) - → 计划写入 CareerTask / 收件箱,显示在工作区 - → 如果建议做 Role Intelligence → 用户一键开始(L1,在范围内自动执行) + → 创建一次有界的 Role Intelligence 后台任务(L1 Prepare) + → Career Director 同时做一次有边界的职业评估 + → 调研结果与行动计划写入同一岗位工作区 / CareerTask / 收件箱 ``` - 没有可用 Agent 或模型时:岗位照常保存,工作区照常可用,记录一个可重试的 blocked CareerTask,并说明需要配置什么。**不能**用脚本伪造一份评估。 -- Role Intelligence **不会**因为 `JOB_SAVED` 自动启动。 +- 保存岗位默认启动一次 Role Intelligence 准备任务;每个事件按幂等键去重。没有可用 Provider 时保留明确、可重试的失败或阻塞状态,不能伪造研究结果。后续 Career Director 不得为同一事件重复启动相同调研;新事实采用和外部发送仍走独立审核与授权。 ## 6. Role Intelligence diff --git a/docs/09-quality-and-release.md b/docs/09-quality-and-release.md index eaba55f9..28a55c44 100644 --- a/docs/09-quality-and-release.md +++ b/docs/09-quality-and-release.md @@ -85,6 +85,8 @@ Codex、Claude Code、OpenCode、WorkBuddy、Pi 等宿主共用同一组业务 代码签名证书(Windows / macOS)、升级路径(需要上一版安装包)、Tauri updater、干净系统上的陌生用户人工验收。实时状态以 `KNOWN_ISSUES.md` 为准。 +Owner Dogfood 安装包可以通过 `Build & Release` 的手动输入 `publish_candidate=true` 在 GitHub runner 构建并发布为 `dogfood-<产品版本>-` 预发行版。它仍依赖完整 CI、产物校验、秘密审计和 installed-app smoke;产物明确标注未签名、尚未通过全部 Public Release / 真人验收。默认不发布,正式 `v*` tag 的签名和 readiness 门槛保持不变。 + ## 10. 证据与报告规则 - 新报告放在 `docs/evidence/reports/YYYY-MM-DD-.md`,格式遵循 `docs/evidence/report-schema.json`(EvolveBench 的格式见 `docs/evidence/evolve-bench-schema.json`)。 diff --git a/frontend/src/components/workbench/DecisionPlanView.tsx b/frontend/src/components/workbench/DecisionPlanView.tsx index 7798ba6b..3c514e62 100644 --- a/frontend/src/components/workbench/DecisionPlanView.tsx +++ b/frontend/src/components/workbench/DecisionPlanView.tsx @@ -26,7 +26,9 @@ import { PLAN_STATUS_LABELS, RISK_LABELS, groupStatusTone, + groupAdvisoryHints, isGroupActionable, + isGroupRejectable, nodeStatusTone, type DecisionGroupView, type DecisionNodeView, @@ -51,17 +53,6 @@ const TONE_CHIP: Record = { muted: "border-[var(--border)] text-[var(--foreground-muted)]", }; -const REVIEW_WARNING_LABELS: Record = { - missing_before: "缺少 Before", - missing_after: "缺少 After", - missing_why: "缺少修改理由", - missing_evidence: "缺少证据说明", - missing_current_source: "未绑定来源版本", - missing_scope: "未标注作用范围", - input_request_not_bound_to_group: "Ask 未绑定独立问题卡", - auto_fallback_requires_confirmation: "自动执行无人接管,已降级为手动确认", -}; - function StatusChip({ status, labels }: { status: string; labels: Record }) { const label = labels[status] ?? status; return ( @@ -229,6 +220,8 @@ function DecisionGroupCard({ const [adjustOpen, setAdjustOpen] = useState(false); const [feedback, setFeedback] = useState(""); const actionable = isGroupActionable(group); + const rejectOnly = !actionable && isGroupRejectable(group); + const advisoryHints = actionable ? groupAdvisoryHints(group) : []; const digestsReady = Boolean(plan.plan_digest && group.group_digest); const display = group.display || {}; const knownKeys: Record = { before: true, after: true, why: true, evidence: true }; @@ -315,20 +308,6 @@ function DecisionGroupCard({ - {group.reviewability?.status === "ready" && group.reviewability.reason_codes?.length > 0 && ( -
-

信息提示,不影响确认

-

- {group.reviewability.reason_codes - .map((code) => REVIEW_WARNING_LABELS[code] ?? code) - .join(" · ")} -

-
- )} - {!digestsReady && actionable && (

摘要缺失,无法安全批准此分组。 @@ -350,6 +329,31 @@ function DecisionGroupCard({

)} + {advisoryHints.length > 0 && ( +

+ 提示:{advisoryHints.join("、")}(不影响批准) +

+ )} + {rejectOnly && ( +
+

+ 该分组暂不能批准{group.reviewability.reason_codes.length ? `(${group.reviewability.reason_codes.join("、")})` : ""},可以先拒绝让 Agent 重新准备。 +

+ +
+ )} {actionable && (
diff --git a/frontend/src/components/workbench/PendingProposalReview.test.tsx b/frontend/src/components/workbench/PendingProposalReview.test.tsx index 91a6f9c2..d7df481a 100644 --- a/frontend/src/components/workbench/PendingProposalReview.test.tsx +++ b/frontend/src/components/workbench/PendingProposalReview.test.tsx @@ -245,6 +245,91 @@ describe("PendingProposalReview", () => { expect(screen.queryByText(/同一 Run 已收到回执/)).not.toBeInTheDocument(); }); + it("keeps approval available when the review packet omits a before/after preview", async () => { + const incompletePlan = { + ...resumePlan, + groups: resumePlan.groups.map((group) => ({ + ...group, + reviewability: { + status: "ready", + reason_codes: [], + advisory_codes: ["missing_before", "missing_after", "missing_why", "missing_evidence", "missing_current_source"], + counts_as_user_decision: true, + }, + nodes: group.nodes.map((node) => ({ ...node, display: { summary: "差异预览缺失" } })), + })), + }; + listPlans.mockResolvedValue({ total: 1, items: [incompletePlan] }); + getPlan.mockResolvedValue(incompletePlan); + decideGroup.mockResolvedValue(completedDecision); + + render(); + const user = userEvent.setup(); + + const advisory = await screen.findByTestId("pending-review-advisory"); + expect(advisory).toHaveTextContent("未提供修改前内容"); + expect(advisory).toHaveTextContent("未提供修改后内容"); + expect(advisory).toHaveTextContent("未说明原因"); + expect(advisory).toHaveTextContent("未附来源证据"); + expect(advisory).toHaveTextContent("未固定当前来源版本"); + expect(advisory).toHaveTextContent("不影响批准"); + const approve = screen.getByRole("button", { name: "批准整组:产品经历调整" }); + expect(approve).toBeEnabled(); + await user.click(approve); + await waitFor(() => expect(decideGroup).toHaveBeenCalledOnce()); + }); + + it("blocks approval on a stale source but leaves the rejection action available", async () => { + const stalePlan = { + ...resumePlan, + groups: resumePlan.groups.map((group) => ({ + ...group, + reviewability: { + status: "needs_preparation", + reason_codes: ["source_changed_or_unavailable"], + advisory_codes: [], + counts_as_user_decision: false, + }, + })), + }; + listPlans.mockResolvedValue({ total: 1, items: [stalePlan] }); + getPlan.mockResolvedValue(stalePlan); + decideGroup.mockResolvedValue({ ok: true, group: { status: "rejected" }, receipts: [] }); + + render(); + const user = userEvent.setup(); + + expect(await screen.findByText(/来源内容已变化,需要重新准备/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "批准整组:产品经历调整" })).toBeDisabled(); + const reject = screen.getByRole("button", { name: "拒绝整组:产品经历调整" }); + expect(reject).toBeEnabled(); + await user.click(reject); + await waitFor(() => expect(decideGroup).toHaveBeenCalledWith( + stalePlan.id, + stalePlan.groups[0].id, + expect.objectContaining({ approve: false, plan_digest: stalePlan.digest, group_digest: stalePlan.groups[0].digest }), + )); + }); + + it("keeps approve and reject unavailable while an Operation effect is unknown", async () => { + const unknownPlan = { + ...resumePlan, + groups: resumePlan.groups.map((group) => ({ + ...group, + nodes: group.nodes.map((node) => ({ ...node, status: "uncertain" })), + })), + }; + listPlans.mockResolvedValue({ total: 1, items: [unknownPlan] }); + getPlan.mockResolvedValue(unknownPlan); + + render(); + + expect(await screen.findByText("执行结果尚未核对,暂不能批准或拒绝。")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "批准整组:产品经历调整" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "拒绝整组:产品经历调整" })).toBeDisabled(); + expect(decideGroup).not.toHaveBeenCalled(); + }); + it("folds completed, rejected and replaced groups to their result summary", async () => { const terminalGroups: typeof resumePlan.groups = ["completed", "rejected", "replaced"].map((status, index) => ({ ...resumePlan.groups[0], diff --git a/frontend/src/components/workbench/PendingProposalReview.tsx b/frontend/src/components/workbench/PendingProposalReview.tsx index 28423610..7f581c4c 100644 --- a/frontend/src/components/workbench/PendingProposalReview.tsx +++ b/frontend/src/components/workbench/PendingProposalReview.tsx @@ -14,6 +14,7 @@ import { type AgentProposalPlan, } from "@/lib/api"; import { safeClientErrorMessage } from "@/lib/safe-error"; +import { reviewabilityCodeLabels } from "@/lib/decisionPlans"; import { resetOnboardingForFreshStart } from "@/lib/useOnboarding"; import { resolveApiBase } from "@/lib/apiBase"; import { useSWRConfig } from "swr"; @@ -111,6 +112,8 @@ function requestRunReviewRefresh(runId: string) { window.dispatchEvent(new CustomEvent("offeru-run-review-refresh", { detail: { run_id: runId } })); } +// Advisory only: a missing before/after preview is shown as a quiet hint and +// never disables approval (Registry direct edits carry no diff material). function groupHasReviewableDiff(group: AgentConfirmationGroup) { return group.nodes.length > 0 && group.nodes.every((node) => { const display = node.display; @@ -122,6 +125,10 @@ function groupHasReviewableDiff(group: AgentConfirmationGroup) { }); } +function groupHasUnknownExecution(group: AgentConfirmationGroup) { + return group.nodes.some((node) => ["executing", "uncertain", "failed"].includes(node.status)); +} + function validDecisionTarget(plan: AgentProposalPlan, group: AgentConfirmationGroup) { return /^plan_[0-9a-f]{32}$/.test(plan.id) && /^group_[0-9a-f]{32}$/.test(group.id) @@ -722,14 +729,26 @@ export function PendingProposalReview() { const busy = busyDecisionId === group.id; const targetValid = validDecisionTarget(selectedPlan, group); const reviewable = groupHasReviewableDiff(group); + const reviewabilityStatus = group.reviewability?.status; + const unknownExecution = groupHasUnknownExecution(group) + || group.status === "needs_reconciliation" + || reviewabilityStatus === "needs_reconciliation"; + const advisoryCodes = group.reviewability?.advisory_codes || []; + const advisoryHints = reviewabilityCodeLabels(advisoryCodes); + if (!reviewable && !advisoryCodes.some((code) => ["missing_before", "missing_after"].includes(code))) { + advisoryHints.push("未提供修改前后对照"); + } + const blockerCodes = group.reviewability?.reason_codes || []; + const blockerHints = reviewabilityCodeLabels(blockerCodes); const groupStatus = displayedGroupStatus(selectedPlan, group); const terminalGroup = ["completed", "rejected", "replaced"].includes(groupStatus); const canDecideGroup = ["sealed", "executing"].includes(selectedPlan.status); const canApprove = planListReady && !planError && !planLoading && canDecideGroup - && group.status === "pending" && targetValid && reviewable + && group.status === "pending" && targetValid && !unknownExecution + && (!reviewabilityStatus || reviewabilityStatus === "ready") && ["sealed", "executing"].includes(selectedPlan.status); const canReject = planListReady && !planError && !planLoading && canDecideGroup - && group.status === "pending" && targetValid; + && group.status === "pending" && targetValid && !unknownExecution; const feedback = decisionResults[group.id]; const responseReceipts = feedback?.receipts || []; const continuations = [ @@ -817,8 +836,8 @@ export function PendingProposalReview() { rationale={node.display.rationale} /> ) : ( -

- 缺少可审核的修改前后内容,当前组不能批准。 +

+ 修改前后内容未完整提供。

)} @@ -856,7 +875,17 @@ export function PendingProposalReview() { {group.status === "pending" && canDecideGroup && (
{!targetValid &&

摘要或标识格式无效,决定入口已关闭。

} - {targetValid && !reviewable &&

请先补全每个节点的修改前后内容。

} + {targetValid && unknownExecution &&

执行结果尚未核对,暂不能批准或拒绝。

} + {targetValid && !unknownExecution && reviewabilityStatus && reviewabilityStatus !== "ready" && ( +

+ 当前不能批准:{blockerHints.join("、") || "需要先修复审核内容"}。你仍可拒绝此组。 +

+ )} + {targetValid && advisoryHints.length > 0 && ( +

+ 提示:{advisoryHints.join("、")}(不影响批准) +

+ )}