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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down
92 changes: 43 additions & 49 deletions backend/app/services/agentic_interaction_policy.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -71,16 +69,27 @@ 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:
reasons.append("missing_after")
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")
Expand All @@ -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]:
Expand All @@ -118,56 +124,44 @@ 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 "")
outcome = _requested_outcome(group)
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:
Expand Down
25 changes: 19 additions & 6 deletions backend/app/services/proposal_plan_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]}

Expand All @@ -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))
Expand All @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions backend/app/services/proposal_plan_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading