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
4 changes: 2 additions & 2 deletions .github/workflows/branch-freshness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ permissions:

jobs:
branch-freshness:
name: PR includes latest main
name: PR baseline freshness (advisory)
runs-on: ubuntu-latest

steps:
Expand All @@ -23,7 +23,7 @@ jobs:
run: |
git fetch --no-tags origin "${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}"

- name: Reject stale PR baseline
- name: Report stale PR baseline
run: |
python backend/scripts/dev/branch_guard.py \
--mode pr \
Expand Down
65 changes: 65 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,49 @@ permissions:
contents: read

jobs:
changes:
name: Detect changed areas
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
agent_runtime: ${{ steps.filter.outputs.agent_runtime }}
extension: ${{ steps.filter.outputs.extension }}
tauri: ${{ steps.filter.outputs.tauri }}
ci: ${{ steps.filter.outputs.ci }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- id: filter
name: Classify changed paths
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
set -euo pipefail
base="${BASE_SHA:-}"
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then
base="$(git rev-parse HEAD^ 2>/dev/null || git rev-parse HEAD)"
fi
git diff --name-only "$base" HEAD > "$RUNNER_TEMP/offeru-changed-files.txt"
flag() {
if grep -Eq "$1" "$RUNNER_TEMP/offeru-changed-files.txt"; then echo true; else echo false; fi
}
{
echo "backend=$(flag '^(backend/|requirements.*|pyproject\.toml)')"
echo "frontend=$(flag '^frontend/')"
echo "agent_runtime=$(flag '^agent-runtime/')"
echo "extension=$(flag '^extension/')"
echo "tauri=$(flag '^frontend/src-tauri/')"
echo "ci=$(flag '^(\.github/workflows/|AGENTS\.md$|backend/scripts/dev/branch_guard\.py$)')"
} >> "$GITHUB_OUTPUT"

backend-tests:
name: Backend tests
needs: changes
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ci == 'true' }}
runs-on: ubuntu-latest

steps:
Expand All @@ -36,22 +77,29 @@ jobs:
run: python -m pytest tests -q

- name: Audit release-facing product claims
if: ${{ github.event_name != 'pull_request' }}
run: python backend/scripts/release/audit_product_claims.py --repo-root . --json

- name: Audit architecture and Operation Registry boundaries
if: ${{ github.event_name != 'pull_request' }}
run: python backend/scripts/release/audit_architecture.py --json

- name: Audit release severity ledger
if: ${{ github.event_name != 'pull_request' }}
run: python backend/scripts/release/audit_release_severity.py --repo-root . --json

- name: Audit release checklist semantics
if: ${{ github.event_name != 'pull_request' }}
run: python backend/scripts/release/audit_release_readiness.py --repo-root . --json

- name: Audit release version consistency
if: ${{ github.event_name != 'pull_request' }}
run: python backend/scripts/release/audit_version_consistency.py --repo-root . --json

frontend-quality:
name: Frontend typecheck and build
needs: changes
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.frontend == 'true' || needs.changes.outputs.ci == 'true' }}
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -81,11 +129,14 @@ jobs:
run: npm run build

- name: Audit production dependencies
continue-on-error: ${{ github.event_name == 'pull_request' }}
working-directory: frontend
run: npm audit --omit=dev --audit-level=high

agent-runtime-quality:
name: Agent runtime install and audit
needs: changes
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.agent_runtime == 'true' || needs.changes.outputs.ci == 'true' }}
runs-on: ubuntu-latest

steps:
Expand All @@ -103,11 +154,15 @@ jobs:
run: npm ci

- name: Audit agent runtime production dependencies
continue-on-error: ${{ github.event_name == 'pull_request' }}
working-directory: agent-runtime
run: npm audit --omit=dev --audit-level=high

rust-security-audit:
name: RustSec dependency audit
needs: changes
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.tauri == 'true' || needs.changes.outputs.ci == 'true' }}
continue-on-error: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest

steps:
Expand All @@ -132,6 +187,8 @@ jobs:

extension-quality:
name: Browser extension typecheck, test and build
needs: changes
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.extension == 'true' || needs.changes.outputs.ci == 'true' }}
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -165,10 +222,12 @@ jobs:
run: python ../backend/scripts/release/audit_architecture.py --generated-extension-artifacts --json

- name: Audit extension production dependencies
continue-on-error: ${{ github.event_name == 'pull_request' }}
working-directory: extension
run: npm audit --omit=dev --audit-level=high

browser-smoke:
if: ${{ github.event_name != 'pull_request' }}
name: Isolated browser release smoke (${{ matrix.scenario }})
runs-on: ubuntu-latest
needs: [backend-tests, frontend-quality]
Expand Down Expand Up @@ -350,6 +409,7 @@ jobs:
if-no-files-found: ignore

reliability-failure-recovery:
if: ${{ github.event_name != 'pull_request' }}
name: Cross-process provider failure and restart recovery
runs-on: ubuntu-latest
needs: [backend-tests]
Expand All @@ -374,6 +434,7 @@ jobs:
run: python backend/scripts/e2e/test_public_release_failure_recovery.py

critical-browser-repeatability:
if: ${{ github.event_name != 'pull_request' }}
name: Critical new-user browser repeatability (${{ matrix.repeat }})
runs-on: ubuntu-latest
needs: [backend-tests, frontend-quality]
Expand Down Expand Up @@ -529,6 +590,7 @@ jobs:
done

migration-browser-smoke:
if: ${{ github.event_name != 'pull_request' }}
name: Previous-release migration browser smoke
runs-on: ubuntu-latest
needs: [backend-tests, frontend-quality]
Expand Down Expand Up @@ -636,6 +698,7 @@ jobs:
fi

desktop-package:
if: ${{ github.event_name != 'pull_request' }}
name: Windows desktop package
runs-on: windows-latest
needs: [backend-tests, frontend-quality, extension-quality, browser-smoke, reliability-failure-recovery, critical-browser-repeatability, migration-browser-smoke]
Expand Down Expand Up @@ -782,11 +845,13 @@ jobs:
if-no-files-found: error

macos-package:
if: ${{ github.event_name != 'pull_request' }}
needs: [backend-tests, frontend-quality, extension-quality]
uses: ./.github/workflows/macos-package.yml
secrets: inherit

desktop-installed-smoke:
if: ${{ github.event_name != 'pull_request' }}
name: Windows installed-app and sidecar smoke
runs-on: windows-latest
needs: [desktop-package]
Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,11 @@ Issues 和 PRD 使用当前 Git remote 对应的 GitHub Issues,统一用 `gh`

### 本地分支预算与归档

- **分支规则现在有机器闸门,不再只靠提醒**:开始一轮开发前运行 `python backend/scripts/dev/branch_guard.py --mode start`;集成 worker 前运行 `--mode integrate`;任何真人/Desktop/安装包验收前必须运行 `--mode owner-test`。Guard 会先 fetch `origin/main`,HEAD 落后 main 或本地命名分支超过 3 条时直接失败;集成/真人验收默认还要求 clean worktree。详细规则以 `backend/scripts/dev/branch_guard.py` 为准;原始说明已归档在 `docs/archive/pre-2026-10/agents/branch-and-test-baseline.md`。
- **分支 Guard 优先服务开发速度,真人验收才 fail-closed**:开始一轮开发前运行 `python backend/scripts/dev/branch_guard.py --mode start`;集成 worker 前运行 `--mode integrate`;任何真人/Desktop/安装包验收前必须运行 `--mode owner-test`。start / integrate / pr 模式下,落后 main、dirty worktree、命名分支超预算只输出 warning,不阻止施工;只有 owner-test 要求最新 baseline、clean worktree 与精确运行物身份。详细规则以 `backend/scripts/dev/branch_guard.py` 为准。
- **子代理默认只用 detached worktree,不创建永久 worker 分支**:主代理先记录唯一 integration baseline SHA,再用 `git worktree add --detach <H:/tmp/offeru/...> <integration-sha>` 隔离;子代理提交后返回 commit SHA,由主代理尽早审查并合入唯一集成分支。不得为 Luna/review/fix/experiment 每项工作长期新增命名分支。
- **真人测试必须证明“源码身份”和“运行物身份”一致**:`--mode owner-test` 输出 HEAD、origin/main、ahead/behind、dirty、branch/worktree 数;启动 Desktop/后端后再用 `--runtime-health-url http://127.0.0.1:8766/api/health` 核对运行时 `build_identity.commit == HEAD`。源码已更新但实际运行旧 EXE/sidecar/DMG 时,测试结果一律不能算当前版本验收。
- **PR 也不得长期漂在旧 main 上**:`.github/workflows/branch-freshness.yml` 从 PR 实际 head 检查当前 base 是否为其祖先。失败时先同步当前 main 再继续集成/验收;不要在 stale PR 上继续堆新功能。
- **本地最多三条分支**:保留 `main`、当前开发分支、当前集成分支。此次为 `main`、`feat/agent-contract-desktop-binding`、`codex/proposal-v2-integration-20261002`;任务切换时调整职责,不能不断累加实验、评审或子代理分支。需要隔离并行时优先 `git worktree add --detach`,主代理审查并集成明确提交。
- **PR freshness 是提醒,不是开发闸门**:`.github/workflows/branch-freshness.yml` 从 PR 实际 head 检查当前 base 是否为其祖先;落后 main 时提示尽快同步,但不阻止继续开发或创建 PR。进入 owner-test / release 前必须同步到当前 main。
- **本地三条分支是建议预算,不是硬限制**:通常保留 `main`、当前开发分支、当前集成分支;并行施工需要更多命名分支时可以继续,不得因为数量本身中断开发。仍优先用 detached worktree,任务完成后再归档/清理。
- **减少分支不等于合并所有功能**:已合并内容与 cherry-pick 后的等价修改核对后收尾;未验收内容列入待集成清单,不能为了数量目标混进 main。移除含独有提交的分支前,必须生成并验证可恢复 Git bundle、保存原分支 SHA 和工作树状态;有未提交改动的 worktree 保留目录及文件,不 force-remove、不 reset。归档不计为功能完成。
- **归档必须能实际恢复**:持久归档放在仓库 Git common directory 的 `archives/`,不能只有临时目录中的单份备份;在隔离目录恢复并比对原 SHA,记录恢复命令。远端分支和开放 PR 单独核对,未获授权不删除或改写远端。当前清单与恢复步骤见 `docs/agents/branch-consolidation-20261005.md`。

Expand Down
69 changes: 44 additions & 25 deletions backend/app/services/agentic_interaction_policy.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Project Plan execution facts into the user's interaction states.

This module does not authorize or execute Operations. It keeps malformed or
stale review packets out of the user Inbox and leaves execution authority with
the existing Proposal Plan and Operation Registry.
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.
"""
from __future__ import annotations

Expand Down Expand Up @@ -92,7 +94,10 @@ def _requested_outcome(group: Mapping[str, Any]) -> str:
outcome = str(display.get("interaction_outcome") or "").strip().upper()
if outcome:
return outcome
return "AUTO" if group.get("risk") == "prepare" else "REVIEW"
# 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"


def assess_group(group: Mapping[str, Any], *, source_current: bool | None = True) -> dict[str, Any]:
Expand All @@ -113,42 +118,56 @@ 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"}

reasons = _review_packet_reasons(group)
if source_current is False:
reasons.append("source_changed_or_unavailable")
warnings = _review_packet_reasons(group)

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"}
if risk == "external" or operations & _DESTRUCTIVE_OPERATIONS:
outcome = "AUTHORIZE"
if outcome == "AUTHORIZE" and reasons:
# Owner-authorized destructive actions show their own preview payload
# and intentionally clear sources; only the affected scope still
# gates them. Diff material and source freshness do not apply.
reasons = [code for code in reasons if code == "missing_scope"]
if reasons:
return {"reviewability": {"status": "needs_preparation", "reason_codes": sorted(set(reasons)), "counts_as_user_decision": False},

# 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:
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 == "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":
# 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"}
# 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"

if outcome == "AUTHORIZE":
return {"reviewability": {"status": "ready", "reason_codes": [], "counts_as_user_decision": True},
return {"reviewability": {"status": "ready", "reason_codes": warning_codes, "counts_as_user_decision": True},
"interaction_state": "needs_user_authorization"}
if outcome == "REVIEW":
return {"reviewability": {"status": "ready", "reason_codes": [], "counts_as_user_decision": True},
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": [], "counts_as_user_decision": False},
"interaction_state": "system_recovering"}
return {"reviewability": {"status": "ready", "reason_codes": warning_codes, "counts_as_user_decision": True},
"interaction_state": "needs_user_review"}


def run_interaction_state(run: Mapping[str, Any]) -> str:
Expand Down
24 changes: 14 additions & 10 deletions backend/scripts/dev/branch_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,25 +105,29 @@ def evaluate(
warnings: list[str] = []

if state.behind > 0:
failures.append(
message = (
f"HEAD is {state.behind} commit(s) behind {state.base_ref}; "
"refresh/rebase the test or integration baseline first"
"refresh/rebase before owner acceptance or release evidence"
)
if state.mode == "owner-test":
failures.append(message)
else:
warnings.append(message)

if state.mode in {"owner-test", "integrate", "pr"} and state.dirty and not allow_dirty:
if state.mode == "owner-test" and state.dirty and not allow_dirty:
failures.append(
"working tree is dirty; owner/integration evidence must name an exact committed source identity"
"working tree is dirty; owner acceptance evidence must name an exact committed source identity"
)
elif state.dirty:
warnings.append(
"working tree is dirty; results must be reported as dirty_worktree "
"and cannot represent a clean build"
"working tree is dirty; development/integration may continue, but results must be reported as "
"dirty_worktree and cannot represent clean owner/release evidence"
)

if state.mode != "pr" and state.named_local_branches > max_named_branches:
failures.append(
f"{state.named_local_branches} local named branches exceed the budget of "
f"{max_named_branches}; use detached worker worktrees or archive/consolidate first"
if state.named_local_branches > max_named_branches:
warnings.append(
f"{state.named_local_branches} local named branches exceed the advisory budget of "
f"{max_named_branches}; prefer detached worker worktrees, but do not block active development"
)

if state.branch == "main" and state.ahead > 0:
Expand Down
Loading
Loading