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
6 changes: 2 additions & 4 deletions .egg/phase-permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,25 +121,23 @@
"phase_file_restrictions": {
"refine": {
"allowed_patterns": [
".egg-state/contracts/*",
".egg-state/drafts/*analysis*",
".egg-state/checkpoints/*",
".egg-state/agent-outputs/*",
".egg-state/reviews/*",
".egg-state/agent-anchors/*"
],
"description": "Refine phase can only push contracts, analysis drafts, checkpoints, agent outputs, reviews, and agent anchors"
"description": "Refine phase can only push analysis drafts, checkpoints, agent outputs, reviews, and agent anchors (contracts go through the contract API, not git - #2979)"
},
"plan": {
"allowed_patterns": [
".egg-state/contracts/*",
".egg-state/drafts/*plan*",
".egg-state/checkpoints/*",
".egg-state/agent-outputs/*",
".egg-state/reviews/*",
".egg-state/agent-anchors/*"
],
"description": "Plan phase can only push contracts, plan drafts, checkpoints, agent outputs, reviews, and agent anchors"
"description": "Plan phase can only push plan drafts, checkpoints, agent outputs, reviews, and agent anchors (contracts go through the contract API, not git - #2979)"
},
"implement": {
"blocked_patterns": [
Expand Down
24 changes: 18 additions & 6 deletions gateway/phase_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,33 +581,45 @@ def _get_default_phase_file_restrictions(
"""Get default phase-based file restrictions.

These defaults define which files can be pushed during each phase:
- refine: Only .egg-state/ files (contracts, drafts, checkpoints, agent-outputs, reviews)
- plan: Only .egg-state/ files (contracts, drafts, checkpoints, agent-outputs, reviews)
- refine: Only .egg-state/ files (drafts, checkpoints, agent-outputs, reviews)
- plan: Only .egg-state/ files (drafts, checkpoints, agent-outputs, reviews)
- implement: Code only, not .egg-state/ (except checkpoints and agent-outputs)
- pr: Everything

Contracts are deliberately NOT git-pushable by agents in any phase
(#2979). The orchestrator is the sole writer of
``.egg-state/contracts/`` — agents mutate contracts through the
``/api/v1/contract/mutate`` gateway route, which proxies to the
orchestrator's ``save_contract`` into the shared pipeline worktree,
and the orchestrator is the only process that commits/pushes those
files. Permitting agent git-pushes of contracts in refine/plan let a
broad ``git add`` land a stale contract snapshot on origin that then
conflicts with the orchestrator's authoritative contract commit at the
post-phase worktree sync. That divergence is what drove the
destructive ``git reset --hard`` reconcile #2979 removed; blocking the
push at the source keeps the conflicting path single-writer so the
sync rebase only ever replays disjoint paths and reconciles cleanly.
"""
return {
PipelinePhase.REFINE: PhaseFileRestriction(
allowed_patterns=[
".egg-state/contracts/*",
".egg-state/drafts/*analysis*",
".egg-state/checkpoints/*",
".egg-state/agent-outputs/*",
".egg-state/reviews/*",
".egg-state/agent-anchors/*",
],
description="Refine phase can only push contracts, analysis drafts, checkpoints, agent outputs, reviews, and agent anchors",
description="Refine phase can only push analysis drafts, checkpoints, agent outputs, reviews, and agent anchors (contracts go through the contract API, not git — #2979)",
),
PipelinePhase.PLAN: PhaseFileRestriction(
allowed_patterns=[
".egg-state/contracts/*",
".egg-state/drafts/*plan*",
".egg-state/checkpoints/*",
".egg-state/agent-outputs/*",
".egg-state/reviews/*",
".egg-state/agent-anchors/*",
],
description="Plan phase can only push contracts, plan drafts, checkpoints, agent outputs, reviews, and agent anchors",
description="Plan phase can only push plan drafts, checkpoints, agent outputs, reviews, and agent anchors (contracts go through the contract API, not git — #2979)",
),
PipelinePhase.IMPLEMENT: PhaseFileRestriction(
blocked_patterns=[
Expand Down
29 changes: 22 additions & 7 deletions gateway/tests/test_phase_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,16 +812,23 @@ def teardown_method(self):

phase_filter._filter = None

def test_refine_phase_allows_contracts(self):
"""Refine phase should allow .egg-state/contracts/ files."""
def test_refine_phase_blocks_contracts(self):
"""Refine phase must NOT allow git-pushing .egg-state/contracts/ (#2979).

The orchestrator is the sole writer of contracts (agents use the
contract API); allowing the push let a stale snapshot race the
orchestrator's commit and drive the destructive sync reset #2979
removed.
"""
from phase_filter import check_phase_file_restrictions

result = check_phase_file_restrictions(
"refine",
[".egg-state/contracts/123.json"],
)

assert result.allowed is True
assert result.allowed is False
assert ".egg-state/contracts/123.json" in result.blocked_files

def test_refine_phase_allows_analysis_drafts(self):
"""Refine phase should allow analysis draft files."""
Expand Down Expand Up @@ -1160,7 +1167,12 @@ def test_refine_phase_allows_reviews(self):
assert result.allowed is True

def test_plan_phase_mixed_allowed_and_disallowed_files(self):
"""Plan phase blocks push when allowed files are mixed with disallowed files."""
"""Plan phase blocks push when allowed files are mixed with disallowed files.

Contracts are now disallowed in plan (#2979) alongside source code,
so both appear in ``blocked_files`` while the agent-output stays
allowed.
"""
from phase_filter import check_phase_file_restrictions

result = check_phase_file_restrictions(
Expand All @@ -1174,19 +1186,22 @@ def test_plan_phase_mixed_allowed_and_disallowed_files(self):

assert result.allowed is False
assert "src/main.py" in result.blocked_files
assert ".egg-state/contracts/plan.json" in result.blocked_files
# Allowed files should NOT appear in blocked_files
assert ".egg-state/agent-outputs/task-planner-output.json" not in result.blocked_files
assert ".egg-state/contracts/plan.json" not in result.blocked_files

def test_plan_phase_allows_multiple_state_files(self):
"""Plan phase allows push with multiple allowed .egg-state/ files."""
"""Plan phase allows push with multiple allowed .egg-state/ files.

Contracts are excluded — they go through the contract API, not git
(#2979).
"""
from phase_filter import check_phase_file_restrictions

result = check_phase_file_restrictions(
"plan",
[
".egg-state/agent-outputs/task-planner-output.json",
".egg-state/contracts/plan.json",
".egg-state/reviews/architect-review.md",
".egg-state/checkpoints/checkpoint-1.json",
],
Expand Down
39 changes: 37 additions & 2 deletions gateway/tests/test_phase_filter_restrictions.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,45 @@ def test_refine_allows_only_egg_state(self):
result = pf.check_phase_file_restrictions("refine", ["src/app.py"])
assert result.allowed is False

def test_refine_allows_contracts(self):
def test_refine_blocks_contracts(self):
"""Agents cannot git-push contracts in refine (#2979).

The orchestrator is the sole writer of ``.egg-state/contracts/``;
agents mutate them through the contract API, not git. Permitting
the push let a stale contract snapshot race the orchestrator's
authoritative commit and drive the destructive sync reset #2979
removed.
"""
pf = PhaseFilter()
result = pf.check_phase_file_restrictions("refine", [".egg-state/contracts/123.json"])
assert result.allowed is True
assert result.allowed is False
assert ".egg-state/contracts/123.json" in result.blocked_files

def test_plan_blocks_contracts(self):
"""Agents cannot git-push contracts in plan either (#2979)."""
pf = PhaseFilter()
result = pf.check_phase_file_restrictions("plan", [".egg-state/contracts/123.json"])
assert result.allowed is False
assert ".egg-state/contracts/123.json" in result.blocked_files

def test_refine_draft_push_bundling_contract_is_rejected(self):
"""A refine push mixing an allowed draft with a contract is denied (#2979).

Phase-restriction enforcement is atomic — one disallowed file
rejects the whole push. Agents push specific paths (``git add
<files>``, not ``git add -A``), so a draft push does not bundle a
contract in practice; if one ever does, the 403-with-hint is the
intended outcome, far preferable to the contract landing on origin
and triggering the destructive divergence reset.
"""
pf = PhaseFilter()
result = pf.check_phase_file_restrictions(
"refine",
[".egg-state/drafts/644-analysis.md", ".egg-state/contracts/123.json"],
)
assert result.allowed is False
assert ".egg-state/contracts/123.json" in result.blocked_files
assert ".egg-state/drafts/644-analysis.md" not in result.blocked_files

def test_refine_allows_analysis_drafts(self):
pf = PhaseFilter()
Expand Down
10 changes: 5 additions & 5 deletions orchestrator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ The `GET /pipelines/{id}/status` endpoint includes a `concurrent` section when t

### Worktree Sync

Before each pipeline phase starts, the orchestrator syncs the agent worktree with the remote branch so downstream code (contract loading, draft reading) sees the full pipeline state. The sync behavior depends on the prior phase's outcome:
Before each pipeline phase starts, and again at each phase boundary, the orchestrator syncs the agent worktree with the remote branch so downstream code (contract loading, draft reading) sees the full pipeline state. The sync is **non-destructive**: it never `git reset --hard` over a commit that isn't provably already on origin (#2979). The behavior depends on the local/remote relationship:

- **Prior phase succeeded + local ahead of remote:** Local commits are pushed to remote before resetting, preserving completed work.
- **Prior phase failed + local ahead of remote:** Local commits are discarded and the worktree is reset to remote, removing incomplete work.
- **Local and remote diverged:** Local commits are rebased onto `origin/<branch>`, auto-resolving conflicts confined to `.egg-state/agent-outputs/` in favor of the remote. If the rebase cannot reconcile (a conflict outside that path), the orchestrator pins the local-only commits under a backup ref (`refs/egg-backup/sync-recovery/<pipeline_id>/<ts>`), hard-resets the worktree to `origin/<branch>`, marks the pipeline FAILED, and surfaces a hard-reset recovery HITL ack (#2792/#2797). _Note: this destructive recovery is being redesigned to reconcile non-destructively — see #2979._
- **Local behind or in-sync with remote:** Standard reset to remote tip.
- **Prior phase succeeded + local ahead of remote:** Local commits are pushed to remote before resetting, preserving completed work. If the push fails, the local commits are left in place (no reset) so completed work is never dropped (#2972).
- **Prior phase failed + local ahead of remote:** Incomplete local commits are discarded and the worktree is reset to remote.
- **Local and remote diverged (ahead AND behind):** The orchestrator rebases the local commits onto `origin/<branch>` (reusing the gateway push-reject autoresolve, which auto-resolves conflicts under `.egg-state/agent-outputs/`). If the rebase can't reconcile the divergence, it aborts back to the clean local HEAD — the committed work stays intact — and the orchestrator **pauses the pipeline** (`AWAITING_HUMAN`, not `FAILED`) on a reconcile HITL, pinning the tip under a `refs/egg-backup/sync-recovery/<id>/<ts>` ref. Nothing is discarded. The operator reconciles the worktree manually and acks the HITL; the in-loop phase-boundary callers then re-run the sync and resume the phase's post-processing inline (no full re-run), while the `populate_contract` route returns HTTP 409 for the operator to re-run after reconciling. In normal operation this path is unreachable: agents cannot git-push `.egg-state/contracts/` (they mutate contracts through the contract API), so the orchestrator is the sole writer of the only non-`agent-outputs` path both sides touched and the rebase only ever replays disjoint paths.
- **Local behind or in-sync with remote:** Standard reset to remote tip (origin is strictly ahead — nothing local to lose).

### HITL Decisions

Expand Down
Loading
Loading